dsh-plugin-taskflow 0.0.0-stage → 0.1.1
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 +246 -2
- package/client/client.js +1481 -0
- package/cordis.patch.yml +8 -0
- package/lib/index.js +1421 -0
- package/package.json +52 -4
package/client/client.js
ADDED
|
@@ -0,0 +1,1481 @@
|
|
|
1
|
+
window.__ModuleLoader__.load({
|
|
2
|
+
id: 'dsh-plugin-taskflow',
|
|
3
|
+
factory: (require) => {
|
|
4
|
+
var module = { exports: {} }
|
|
5
|
+
var exports = module.exports
|
|
6
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' })
|
|
7
|
+
|
|
8
|
+
const React = require('react')
|
|
9
|
+
const ReactDOM = require('react-dom')
|
|
10
|
+
const h = React.createElement
|
|
11
|
+
|
|
12
|
+
const NS = 'taskflow'
|
|
13
|
+
const ID = 'dsh-plugin-taskflow'
|
|
14
|
+
// Bumped whenever the browser half changes: the host reports the live value
|
|
15
|
+
// in /taskflow/health, which is how a stale page is detected.
|
|
16
|
+
const CLIENT_BUILD = 'c95'
|
|
17
|
+
/**
|
|
18
|
+
* true = shadow DSH's own todo panel (id 'todo', priority -1)
|
|
19
|
+
* false = render beside it (unique id), so both lists are visible
|
|
20
|
+
* Override at runtime: window.__DSH_TASKFLOW_REPLACE_DOCK__ = true/false
|
|
21
|
+
*/
|
|
22
|
+
const DEFAULT_DOCK_MODE = 'custom'
|
|
23
|
+
const POLL_MS_ACTIVE = 900
|
|
24
|
+
const POLL_MS_IDLE = 2600
|
|
25
|
+
|
|
26
|
+
/* ------------------------------------------------------------------ *
|
|
27
|
+
* Copy
|
|
28
|
+
* ------------------------------------------------------------------ */
|
|
29
|
+
|
|
30
|
+
const ZH = {
|
|
31
|
+
'strip.open': '打开任务流面板',
|
|
32
|
+
'strip.expand': '展开任务列表',
|
|
33
|
+
'strip.collapse': '收起任务列表',
|
|
34
|
+
'strip.summary': '段小结',
|
|
35
|
+
'status.pending': '未开始',
|
|
36
|
+
'status.in_progress': '进行中',
|
|
37
|
+
'status.completed': '已完成',
|
|
38
|
+
'status.allDone': '全部完成',
|
|
39
|
+
'status.waiting': '等待确认',
|
|
40
|
+
'status.waitingHint': '本轮结束,等你确认后继续',
|
|
41
|
+
'status.delegated': '子 agent 执行中',
|
|
42
|
+
'card.pending': '正在整理本段小结…',
|
|
43
|
+
'card.turn': '第 {turn} 段',
|
|
44
|
+
'card.steps': '{count} 步',
|
|
45
|
+
'card.files': '{count} 个文件',
|
|
46
|
+
'card.expand': '展开小结',
|
|
47
|
+
'card.mechanical': '规则摘要',
|
|
48
|
+
'panel.title': '任务流',
|
|
49
|
+
'panel.subtitle': '本会话的任务进度与分段小结',
|
|
50
|
+
'panel.tasks': '当前任务',
|
|
51
|
+
'panel.done': '已完成',
|
|
52
|
+
'panel.segments': '分段小结',
|
|
53
|
+
'panel.empty': '还没有小结。完成一段工作后会自动生成,也可以点下方按钮手动整理。',
|
|
54
|
+
'panel.emptySession': '本会话暂无任务清单',
|
|
55
|
+
'panel.planning': '正在拆解任务…',
|
|
56
|
+
'panel.noTasks': '本会话还没有任务列表(agent 使用 todo_write 后出现)。',
|
|
57
|
+
'panel.summarize': '整理最近一段',
|
|
58
|
+
'panel.summarizing': '正在整理…',
|
|
59
|
+
'panel.refresh': '刷新',
|
|
60
|
+
'panel.manual': '手动摘要',
|
|
61
|
+
'panel.time': '用时 {value}',
|
|
62
|
+
'panel.guide.title': '任务流',
|
|
63
|
+
'panel.guide.description': '查看任务进度与每一段工作的自动小结。',
|
|
64
|
+
'panel.updated': '更新于',
|
|
65
|
+
'panel.cost': '{model} · {tokens} tokens',
|
|
66
|
+
'tasks.header': '{total} 个任务 · 完成 {done} · 待办 {open}',
|
|
67
|
+
'tasks.empty': '(空任务列表)',
|
|
68
|
+
'tasks.title': '任务清单',
|
|
69
|
+
'tasks.moreDone': '还有 {count} 项已完成',
|
|
70
|
+
'settings.title': '任务流',
|
|
71
|
+
'settings.subtitle': '输入框上方显示哪一种任务列表',
|
|
72
|
+
'settings.custom': 'TaskFlow 面板',
|
|
73
|
+
'settings.customHint': '进度环、当前任务、计时、等待确认/子 agent 状态,点条目看任务小结',
|
|
74
|
+
'settings.builtin': 'DSH 内置面板',
|
|
75
|
+
'settings.builtinHint': '官方样式,只有列表与状态,没有计时与小结',
|
|
76
|
+
'settings.note': '切换立即生效,无需刷新页面。',
|
|
77
|
+
'tasks.summary.pending': '小结生成中…',
|
|
78
|
+
'tasks.summary.none': '无小结',
|
|
79
|
+
'tasks.detail.steps': '期间动作',
|
|
80
|
+
'tasks.detail.files': '涉及文件',
|
|
81
|
+
'tasks.detail.start': '开始 {time}',
|
|
82
|
+
'tasks.detail.done': '完成 {time}',
|
|
83
|
+
'tasks.detail.spent': '用时 {value}',
|
|
84
|
+
'strip.sidebar': '查看全部小结',
|
|
85
|
+
'panel.allSummaries': '全部小结',
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const EN = {
|
|
89
|
+
'strip.open': 'Open TaskFlow panel',
|
|
90
|
+
'strip.expand': 'Expand task list',
|
|
91
|
+
'strip.collapse': 'Collapse task list',
|
|
92
|
+
'strip.summary': 'summaries',
|
|
93
|
+
'status.pending': 'pending',
|
|
94
|
+
'status.in_progress': 'in progress',
|
|
95
|
+
'status.completed': 'done',
|
|
96
|
+
'status.allDone': 'all done',
|
|
97
|
+
'status.waiting': 'waiting',
|
|
98
|
+
'status.waitingHint': 'turn ended; waiting for you',
|
|
99
|
+
'status.delegated': 'subagent working',
|
|
100
|
+
'card.pending': 'Summarizing this segment…',
|
|
101
|
+
'card.turn': 'Segment {turn}',
|
|
102
|
+
'card.steps': '{count} steps',
|
|
103
|
+
'card.files': '{count} files',
|
|
104
|
+
'card.expand': 'Expand summary',
|
|
105
|
+
'card.mechanical': 'rule-based',
|
|
106
|
+
'panel.title': 'TaskFlow',
|
|
107
|
+
'panel.subtitle': 'Task progress and per-segment summaries for this session',
|
|
108
|
+
'panel.tasks': 'Current tasks',
|
|
109
|
+
'panel.done': 'Completed',
|
|
110
|
+
'panel.segments': 'Segment summaries',
|
|
111
|
+
'panel.empty': 'No summaries yet. One is generated when a segment of work ends, or press the button below.',
|
|
112
|
+
'panel.emptySession': 'No task list in this session yet',
|
|
113
|
+
'panel.planning': 'Planning tasks…',
|
|
114
|
+
'panel.noTasks': 'This session has no task list yet (it appears once the agent calls todo_write).',
|
|
115
|
+
'panel.summarize': 'Summarize the last segment',
|
|
116
|
+
'panel.summarizing': 'Summarizing…',
|
|
117
|
+
'panel.refresh': 'Refresh',
|
|
118
|
+
'panel.manual': 'Manual summary',
|
|
119
|
+
'panel.time': '{value} elapsed',
|
|
120
|
+
'panel.guide.title': 'TaskFlow',
|
|
121
|
+
'panel.guide.description': 'Task progress plus an automatic summary of every work segment.',
|
|
122
|
+
'panel.updated': 'updated',
|
|
123
|
+
'panel.cost': '{model} · {tokens} tokens',
|
|
124
|
+
'tasks.header': '{total} tasks ({done} done, {open} open)',
|
|
125
|
+
'tasks.empty': '(empty task list)',
|
|
126
|
+
'tasks.title': 'Task list',
|
|
127
|
+
'tasks.moreDone': '{count} more finished',
|
|
128
|
+
'settings.title': 'TaskFlow',
|
|
129
|
+
'settings.subtitle': 'Which task list to show above the composer',
|
|
130
|
+
'settings.custom': 'TaskFlow panel',
|
|
131
|
+
'settings.customHint': 'progress ring, current task, timing, waiting/subagent state, per-task summaries',
|
|
132
|
+
'settings.builtin': 'Built-in DSH panel',
|
|
133
|
+
'settings.builtinHint': 'the shipped list and statuses only',
|
|
134
|
+
'settings.note': 'Applies immediately; no reload needed.',
|
|
135
|
+
'tasks.summary.pending': 'summarizing…',
|
|
136
|
+
'tasks.summary.none': 'no summary',
|
|
137
|
+
'tasks.detail.steps': 'Work done',
|
|
138
|
+
'tasks.detail.files': 'Files',
|
|
139
|
+
'tasks.detail.start': 'started {time}',
|
|
140
|
+
'tasks.detail.done': 'done {time}',
|
|
141
|
+
'tasks.detail.spent': 'took {value}',
|
|
142
|
+
'strip.sidebar': 'All summaries',
|
|
143
|
+
'panel.allSummaries': 'All summaries',
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let bound = null
|
|
147
|
+
let fallbackLang = 'zh'
|
|
148
|
+
|
|
149
|
+
/** Translate one key, falling back to the bundled dictionary. */
|
|
150
|
+
function t(key, vars) {
|
|
151
|
+
let text = ''
|
|
152
|
+
if (bound) {
|
|
153
|
+
try {
|
|
154
|
+
text = bound(key)
|
|
155
|
+
} catch {
|
|
156
|
+
text = ''
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!text || text === key) {
|
|
160
|
+
const dict = fallbackLang === 'en' ? EN : ZH
|
|
161
|
+
text = dict[key] || ZH[key] || key
|
|
162
|
+
}
|
|
163
|
+
if (!vars) return text
|
|
164
|
+
return text.replace(/\{(\w+)\}/g, (match, name) => (vars[name] === undefined ? match : String(vars[name])))
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* ------------------------------------------------------------------ *
|
|
168
|
+
* Styles
|
|
169
|
+
* ------------------------------------------------------------------ */
|
|
170
|
+
|
|
171
|
+
const CSS = `
|
|
172
|
+
.tf-float { display: flex; justify-content: flex-end; padding: 0 2px; }
|
|
173
|
+
.tf-pill { display: inline-flex; align-items: center; gap: 1px; max-width: min(100%, 340px); padding: 2px 8px 2px 3px; border-radius: 999px; background: color-mix(in oklab, currentColor 7%, transparent); color: inherit; font-size: 11.5px; line-height: 18px; opacity: .72; overflow: hidden; }
|
|
174
|
+
.tf-pill:hover { opacity: 1; background: color-mix(in oklab, currentColor 11%, transparent); }
|
|
175
|
+
.tf-ring { display: inline-flex; align-items: center; justify-content: center; flex: 0 0 auto; width: 16px; height: 16px; margin: 0; padding: 0; border: 0; border-radius: 50%; background: transparent; color: inherit; cursor: pointer; opacity: .72; transition: opacity .15s ease; }
|
|
176
|
+
.tf-ring:hover { opacity: 1; }
|
|
177
|
+
.tf-ring-svg { display: block; }
|
|
178
|
+
.tf-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; background: conic-gradient(from 0deg, #ff6b6b, #ffd166, #6bcb77, #4d96ff, #b983ff, #ff6b6b); box-shadow: 0 0 7px rgba(120, 170, 255, .55); animation: tf-pulse 1.8s ease-in-out infinite; }
|
|
179
|
+
.tf-pill-main { display: inline-flex; align-items: center; gap: 6px; min-width: 0; padding: 0 2px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 11.5px; line-height: 18px; cursor: pointer; }
|
|
180
|
+
.tf-pill-count { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .75; }
|
|
181
|
+
.tf-pill-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
182
|
+
.tf-panel { display: flex; flex-direction: column; gap: 12px; padding: 12px; font-size: 12px; box-sizing: border-box; height: 100%; min-height: 0; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: color-mix(in oklab, currentColor 16%, transparent) transparent; }
|
|
183
|
+
.tf-panel > * { flex: 0 0 auto; }
|
|
184
|
+
.tf-panel::-webkit-scrollbar { width: 6px; }
|
|
185
|
+
.tf-panel::-webkit-scrollbar-track { background: transparent; }
|
|
186
|
+
.tf-panel::-webkit-scrollbar-thumb { background: color-mix(in oklab, currentColor 14%, transparent); border-radius: 3px; }
|
|
187
|
+
.tf-panel-head { display: flex; align-items: baseline; gap: 8px; }
|
|
188
|
+
.tf-panel-title { font-size: 13px; font-weight: 600; }
|
|
189
|
+
.tf-panel-sub { opacity: .5; }
|
|
190
|
+
.tf-inline { position: relative; display: flex; flex-direction: column; gap: 3px; width: calc(100% - 2px); box-sizing: border-box; padding: 10px 15px 11px; border-radius: 18px; border: 1px solid rgba(255, 255, 255, .09); background: linear-gradient(180deg, rgba(255, 255, 255, .06), rgba(255, 255, 255, 0) 46%), color-mix(in oklab, currentColor 1.6%, rgba(128, 128, 128, .035)); backdrop-filter: blur(34px) saturate(1.7) brightness(1.05); -webkit-backdrop-filter: blur(34px) saturate(1.7) brightness(1.05); box-shadow: 0 10px 30px rgba(0, 0, 0, .14), inset 0 1px 0 rgba(255, 255, 255, .10), inset 0 -1px 0 rgba(0, 0, 0, .14); }
|
|
191
|
+
@property --tf-angle { syntax: '<angle>'; inherits: false; initial-value: 0deg; }
|
|
192
|
+
@keyframes tf-spin { to { --tf-angle: 360deg; } }
|
|
193
|
+
@keyframes tf-pulse { 0%, 100% { transform: scale(.85); opacity: .7; } 50% { transform: scale(1.12); opacity: 1; } }
|
|
194
|
+
@keyframes tf-glow { 0%, 100% { opacity: .55; } 50% { opacity: 1; } }
|
|
195
|
+
.tf-inline-empty { padding: 5px 10px; }
|
|
196
|
+
.tf-inline-empty .tf-inline-head { opacity: .42; font-size: 10.5px; }
|
|
197
|
+
.tf-empty-dot { flex: 0 0 auto; font-size: 10px; opacity: .7; }
|
|
198
|
+
.tf-inline-head { display: flex; align-items: center; gap: 10px; padding: 1px 0 3px; font-size: 12px; letter-spacing: .01em; transition: color .3s ease; }
|
|
199
|
+
.tf-inline-head[data-state="done"] { color: #4ec97a; }
|
|
200
|
+
.tf-inline-head[data-state="done"] .tf-inline-count { opacity: .8; }
|
|
201
|
+
.tf-inline-head[data-state="done"] .tf-inline-current { opacity: .95; }
|
|
202
|
+
.tf-inline-head[data-state="running"] .tf-inline-current { color: #e3b341; opacity: .85; }
|
|
203
|
+
.tf-inline-count { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .45; }
|
|
204
|
+
.tf-inline-current { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; opacity: .55; }
|
|
205
|
+
.tf-inline-toggle { flex: 0 0 auto; width: 18px; height: 18px; padding: 0; border: 0; background: transparent; color: inherit; opacity: .45; cursor: pointer; display: inline-flex; align-items: center; justify-content: center; }
|
|
206
|
+
.tf-chevron { transition: transform .18s ease; }
|
|
207
|
+
.tf-inline-toggle[data-collapsed="true"] .tf-chevron { transform: rotate(-90deg); }
|
|
208
|
+
.tf-inline-toggle:hover { opacity: .8; }
|
|
209
|
+
.tf-inline-list { margin: 4px 0 0; padding: 0 0 0 1px; list-style: none; display: flex; flex-direction: column; gap: 0; max-height: 150px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: color-mix(in oklab, currentColor 16%, transparent) transparent; }
|
|
210
|
+
.tf-inline-list::-webkit-scrollbar { width: 6px; height: 0; }
|
|
211
|
+
.tf-inline-list::-webkit-scrollbar-track { background: transparent; }
|
|
212
|
+
.tf-inline-list::-webkit-scrollbar-thumb { background: color-mix(in oklab, currentColor 14%, transparent); border-radius: 3px; }
|
|
213
|
+
.tf-inline-list::-webkit-scrollbar-thumb:hover { background: color-mix(in oklab, currentColor 26%, transparent); }
|
|
214
|
+
.tf-item { list-style: none; }
|
|
215
|
+
.tf-item-btn { position: relative; display: flex; align-items: flex-start; gap: 9px; width: 100%; box-sizing: border-box; padding: 5px 6px; border: 0; border-radius: 11px; background: transparent; color: inherit; font: inherit; font-size: 12.5px; line-height: 1.6; text-align: left; cursor: pointer; }
|
|
216
|
+
.tf-item-btn:hover, .tf-item-btn[data-active="true"] { background: color-mix(in oklab, currentColor 7%, transparent); }
|
|
217
|
+
.tf-item-btn[data-delegated="true"] { background: color-mix(in oklab, #8b7cff 10%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklab, #8b7cff 24%, transparent); }
|
|
218
|
+
.tf-inline-head[data-state="delegated"] .tf-inline-current { color: #a99bff; opacity: .95; }
|
|
219
|
+
.tf-item-btn[data-waiting="true"] { background: color-mix(in oklab, #d2a24c 10%, transparent); box-shadow: inset 0 0 0 1px color-mix(in oklab, #d2a24c 48%, transparent), 0 0 10px rgba(210, 162, 76, .10); }
|
|
220
|
+
.tf-item-btn[data-waiting="true"]::before { content: ''; position: absolute; inset: 0; border-radius: 11px; padding: 1px; background: color-mix(in oklab, #d2a24c 60%, transparent); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; pointer-events: none; }
|
|
221
|
+
.tf-item-btn[data-waiting="true"] .tf-item-mark { color: #d2a24c; opacity: .9; }
|
|
222
|
+
.tf-inline-head[data-state="waiting"] .tf-inline-current { color: #d2a24c; opacity: .9; }
|
|
223
|
+
.tf-item-btn[data-status="in_progress"]:not([data-waiting="true"]) { background: color-mix(in oklab, #6aa6ff 9%, transparent); box-shadow: 0 0 16px rgba(110, 160, 255, .16), inset 0 0 14px rgba(110, 160, 255, .10); }
|
|
224
|
+
.tf-item-btn[data-status="in_progress"]:not([data-waiting="true"])::before { content: ''; position: absolute; inset: 0; border-radius: 11px; padding: 2px; background: conic-gradient(from var(--tf-angle, 0deg), #ff6b6b, #ffd166, #6bcb77, #4d96ff, #b983ff, #ff6b6b); -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); -webkit-mask-composite: xor; mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); mask-composite: exclude; opacity: .95; animation: tf-spin 2.4s linear infinite; pointer-events: none; }
|
|
225
|
+
.tf-item-btn[data-status="in_progress"]:not([data-waiting="true"])::after { content: ''; position: absolute; inset: 0; border-radius: 11px; pointer-events: none; background: radial-gradient(130% 160% at 12% 0%, rgba(255, 110, 110, .20), transparent 46%), radial-gradient(130% 160% at 50% 0%, rgba(255, 210, 100, .16), transparent 46%), radial-gradient(130% 160% at 88% 0%, rgba(90, 160, 255, .22), transparent 52%); filter: blur(8px); opacity: .8; animation: tf-glow 2.4s ease-in-out infinite; }
|
|
226
|
+
.tf-item-mark { flex: 0 0 auto; width: 14px; text-align: center; font-size: 12px; line-height: 1.6; }
|
|
227
|
+
.tf-item-btn[data-status="completed"] .tf-item-mark { color: #3fb950; }
|
|
228
|
+
.tf-item-btn[data-status="completed"] .tf-item-text { opacity: .52; }
|
|
229
|
+
.tf-item-btn[data-status="in_progress"] .tf-item-mark { color: #d29922; }
|
|
230
|
+
.tf-item-btn[data-status="in_progress"] .tf-item-text { font-weight: 500; }
|
|
231
|
+
.tf-item-btn[data-status="pending"] .tf-item-mark { opacity: .45; }
|
|
232
|
+
.tf-item-btn[data-status="pending"] .tf-item-text { opacity: .72; }
|
|
233
|
+
.tf-item-text { flex: 1 1 auto; min-width: 0; overflow: hidden; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; line-clamp: 2; white-space: normal; word-break: break-word; }
|
|
234
|
+
.tf-item-parallel { opacity: .5; font-variant-numeric: tabular-nums; }
|
|
235
|
+
.tf-item-time { flex: 0 0 auto; opacity: .3; font-size: 11px; font-variant-numeric: tabular-nums; line-height: 1.6; }
|
|
236
|
+
.tf-item-hint { flex: 0 0 auto; font-size: 11px; opacity: .26; }
|
|
237
|
+
.tf-item-more .tf-item-btn { opacity: .45; font-size: 10.5px; justify-content: center; }
|
|
238
|
+
.tf-item-more .tf-item-btn:hover { opacity: .8; }
|
|
239
|
+
.tf-item-caret { flex: 0 0 auto; font-size: 12px; opacity: .35; }
|
|
240
|
+
.tf-item-btn[data-static="true"] { cursor: default; }
|
|
241
|
+
.tf-item-btn[data-static="true"]:hover { background: transparent; }
|
|
242
|
+
.tf-detail-summary { line-height: 1.65; margin-bottom: 8px; }
|
|
243
|
+
.tf-detail-sec { margin-top: 6px; padding-top: 6px; border-top: 1px solid color-mix(in oklab, currentColor 10%, transparent); }
|
|
244
|
+
.tf-detail-list { margin: 2px 0 0; padding-left: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; opacity: .8; }
|
|
245
|
+
.tf-detail-list li { line-height: 1.5; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
246
|
+
.tf-detail { position: fixed; z-index: 80; min-width: 240px; max-width: min(420px, calc(100vw - 24px)); max-height: 260px; overflow-y: auto; overflow-x: hidden; scrollbar-width: thin; scrollbar-color: color-mix(in oklab, currentColor 16%, transparent) transparent; padding: 10px 12px; border-radius: 10px; border: 1px solid color-mix(in oklab, currentColor 10%, transparent); background: color-mix(in oklab, currentColor 3%, rgba(128, 128, 128, .07)); backdrop-filter: blur(26px) saturate(1.25); -webkit-backdrop-filter: blur(26px) saturate(1.25); box-shadow: 0 10px 30px rgba(0, 0, 0, .18); font-size: 12px; color: inherit; }
|
|
247
|
+
.tf-detail-title { font-weight: 500; line-height: 1.5; margin-bottom: 6px; }
|
|
248
|
+
.tf-detail-row { display: flex; gap: 10px; line-height: 1.7; }
|
|
249
|
+
.tf-detail-key { flex: 0 0 58px; opacity: .5; }
|
|
250
|
+
.tf-detail-val { flex: 1 1 auto; font-variant-numeric: tabular-nums; }
|
|
251
|
+
.tf-row-wrap { list-style: none; }
|
|
252
|
+
.tf-row-btn { width: 100%; padding: 2px 4px; border: 0; border-radius: 6px; background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
253
|
+
.tf-row-btn:hover { background: color-mix(in oklab, currentColor 6%, transparent); }
|
|
254
|
+
.tf-row-caret { flex: 0 0 auto; font-size: 9px; opacity: .35; }
|
|
255
|
+
.tf-row-detail { display: flex; flex-wrap: wrap; gap: 10px; padding: 1px 0 6px 22px; font-size: 11px; opacity: .55; font-variant-numeric: tabular-nums; }
|
|
256
|
+
.tf-tasks { margin: 8px 0 4px; padding: 10px 12px; border-radius: 14px; border: 1px solid color-mix(in oklab, currentColor 13%, transparent); background: color-mix(in oklab, currentColor 3.5%, transparent); }
|
|
257
|
+
.tf-tasks-head { display: flex; align-items: center; gap: 6px; width: 100%; padding: 0; margin-bottom: 6px; border: 0; background: transparent; color: inherit; font: inherit; font-size: 11.5px; opacity: .55; font-variant-numeric: tabular-nums; text-align: left; cursor: pointer; }
|
|
258
|
+
.tf-tasks-head:hover { opacity: .8; }
|
|
259
|
+
.tf-tasks-caret { font-size: 9px; }
|
|
260
|
+
.tf-tasks[data-open="false"] .tf-tasks-head { margin-bottom: 0; }
|
|
261
|
+
.tf-tasks-list { margin: 0; padding: 0; list-style: none; display: flex; flex-direction: column; gap: 2px; }
|
|
262
|
+
.tf-tasks-item { display: flex; align-items: flex-start; gap: 8px; line-height: 1.7; font-size: 12.5px; }
|
|
263
|
+
.tf-tasks-mark { flex: 0 0 auto; width: 14px; text-align: center; font-size: 12px; }
|
|
264
|
+
.tf-tasks-item[data-status="completed"] .tf-tasks-mark { color: #3fb950; }
|
|
265
|
+
.tf-tasks-item[data-status="completed"] .tf-tasks-text { opacity: .68; }
|
|
266
|
+
.tf-tasks-item[data-status="in_progress"] .tf-tasks-mark { color: #d29922; }
|
|
267
|
+
.tf-tasks-item[data-status="in_progress"] .tf-tasks-text { font-weight: 500; }
|
|
268
|
+
.tf-tasks-item[data-status="pending"] .tf-tasks-mark { opacity: .45; }
|
|
269
|
+
.tf-tasks-item[data-status="pending"] .tf-tasks-text { opacity: .72; }
|
|
270
|
+
.tf-tasks-text { flex: 1 1 auto; min-width: 0; }
|
|
271
|
+
.tf-header-btn { display: inline-flex; align-items: center; gap: 4px; height: 22px; padding: 0 6px; border: 1px solid color-mix(in oklab, currentColor 14%, transparent); border-radius: 6px; background: transparent; color: inherit; font: inherit; font-size: 11px; line-height: 1; cursor: pointer; opacity: .72; }
|
|
272
|
+
.tf-header-btn:hover { opacity: 1; background: color-mix(in oklab, currentColor 7%, transparent); }
|
|
273
|
+
.tf-header-count { opacity: .75; font-variant-numeric: tabular-nums; }
|
|
274
|
+
.tf-pop { position: fixed; z-index: 70; width: min(430px, calc(100vw - 24px)); max-height: min(62vh, 560px); overflow: auto; padding: 10px 12px 12px; border-radius: 16px; border: 1px solid color-mix(in oklab, currentColor 16%, transparent); background: color-mix(in oklab, currentColor 8%, rgba(128, 128, 128, .18)); backdrop-filter: blur(18px) saturate(1.15); -webkit-backdrop-filter: blur(18px) saturate(1.15); box-shadow: 0 14px 38px rgba(0, 0, 0, .32); font-size: 12px; color: inherit; }
|
|
275
|
+
.tf-pop-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding-bottom: 6px; margin-bottom: 6px; border-bottom: 1px solid color-mix(in oklab, currentColor 10%, transparent); }
|
|
276
|
+
.tf-pop-title { font-size: 12px; font-weight: 600; opacity: .8; }
|
|
277
|
+
.tf-body { display: flex; flex-direction: column; gap: 12px; }
|
|
278
|
+
.tf-body-actions { display: flex; align-items: center; gap: 10px; }
|
|
279
|
+
.tf-icon-btn { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; padding: 0; border: 0; border-radius: 6px; background: transparent; color: inherit; opacity: .6; cursor: pointer; font-size: 14px; line-height: 1; }
|
|
280
|
+
.tf-icon-btn:hover { opacity: 1; background: color-mix(in oklab, currentColor 8%, transparent); }
|
|
281
|
+
.tf-section { display: flex; flex-direction: column; gap: 6px; }
|
|
282
|
+
.tf-section-title { display: flex; align-items: center; gap: 6px; opacity: .6; font-size: 11px; text-transform: uppercase; letter-spacing: .04em; }
|
|
283
|
+
.tf-seg { border: 1px solid color-mix(in oklab, currentColor 12%, transparent); border-radius: 8px; padding: 8px 10px; display: flex; flex-direction: column; gap: 4px; }
|
|
284
|
+
.tf-seg-head { display: flex; align-items: baseline; gap: 8px; }
|
|
285
|
+
.tf-seg-title { flex: 1 1 auto; font-weight: 500; }
|
|
286
|
+
.tf-seg-meta { flex: 0 0 auto; opacity: .45; font-size: 11px; }
|
|
287
|
+
.tf-seg ul { margin: 0; padding-left: 16px; opacity: .85; }
|
|
288
|
+
.tf-seg li { margin: 2px 0; line-height: 1.55; }
|
|
289
|
+
.tf-list { margin: 0; padding: 2px 0 0; list-style: none; display: flex; flex-direction: column; gap: 3px; font-size: 12px; }
|
|
290
|
+
.tf-row { display: flex; align-items: flex-start; gap: 7px; line-height: 1.5; box-sizing: border-box; }
|
|
291
|
+
.tf-row[data-status="completed"] .tf-row-text { opacity: .55; }
|
|
292
|
+
.tf-row[data-status="in_progress"] .tf-row-text { font-weight: 500; }
|
|
293
|
+
.tf-glyph { flex: 0 0 auto; width: 14px; text-align: center; opacity: .8; }
|
|
294
|
+
.tf-row-text { flex: 1 1 auto; min-width: 0; }
|
|
295
|
+
.tf-row-time { flex: 0 0 auto; font-variant-numeric: tabular-nums; opacity: .5; }
|
|
296
|
+
.tf-btn { align-self: flex-start; padding: 4px 10px; border-radius: 7px; border: 1px solid color-mix(in oklab, currentColor 18%, transparent); background: transparent; color: inherit; font: inherit; font-size: 12px; cursor: pointer; }
|
|
297
|
+
.tf-btn:hover { background: color-mix(in oklab, currentColor 7%, transparent); }
|
|
298
|
+
.tf-btn[disabled] { opacity: .45; cursor: default; }
|
|
299
|
+
.tf-empty { opacity: .5; line-height: 1.6; }
|
|
300
|
+
.tf-error { color: #e5534b; opacity: .9; }
|
|
301
|
+
.tf-settings { display: flex; flex-direction: column; gap: 8px; padding: 4px 0; font-size: 12px; }
|
|
302
|
+
.tf-settings-head { font-size: 13px; font-weight: 600; }
|
|
303
|
+
.tf-settings-sub { opacity: .55; margin-bottom: 4px; }
|
|
304
|
+
.tf-option { display: flex; align-items: flex-start; gap: 9px; padding: 8px 10px; border-radius: 8px; border: 1px solid color-mix(in oklab, currentColor 12%, transparent); background: transparent; color: inherit; font: inherit; text-align: left; cursor: pointer; }
|
|
305
|
+
.tf-option:hover { background: color-mix(in oklab, currentColor 5%, transparent); }
|
|
306
|
+
.tf-option[data-selected="true"] { border-color: color-mix(in oklab, #4ec97a 45%, transparent); background: color-mix(in oklab, #4ec97a 8%, transparent); }
|
|
307
|
+
.tf-option-mark { flex: 0 0 auto; opacity: .7; }
|
|
308
|
+
.tf-option-body { display: flex; flex-direction: column; gap: 2px; }
|
|
309
|
+
.tf-option-title { font-weight: 500; }
|
|
310
|
+
.tf-option-hint { opacity: .55; font-size: 11px; line-height: 1.5; }
|
|
311
|
+
.tf-settings-note { opacity: .45; font-size: 11px; }
|
|
312
|
+
`
|
|
313
|
+
|
|
314
|
+
const reportedGeometry = new Map()
|
|
315
|
+
|
|
316
|
+
/** Report what one surface actually painted, so the host can tell if it is visible. */
|
|
317
|
+
function reportGeometry(part, element, detail) {
|
|
318
|
+
try {
|
|
319
|
+
const rect = element ? element.getBoundingClientRect() : null
|
|
320
|
+
const payload = {
|
|
321
|
+
build: CLIENT_BUILD,
|
|
322
|
+
part,
|
|
323
|
+
mounted: element !== null && element !== undefined,
|
|
324
|
+
visible: !!(rect && rect.width > 0 && rect.height > 0),
|
|
325
|
+
width: rect ? Math.round(rect.width) : 0,
|
|
326
|
+
height: rect ? Math.round(rect.height) : 0,
|
|
327
|
+
detail: String(detail || ''),
|
|
328
|
+
}
|
|
329
|
+
const key = `${payload.mounted}|${payload.visible}|${payload.width}|${payload.height}|${payload.detail}`
|
|
330
|
+
if (reportedGeometry.get(part) === key) return
|
|
331
|
+
reportedGeometry.set(part, key)
|
|
332
|
+
void fetch('/taskflow/client-report', {
|
|
333
|
+
method: 'POST',
|
|
334
|
+
credentials: 'same-origin',
|
|
335
|
+
headers: { 'content-type': 'application/json' },
|
|
336
|
+
body: JSON.stringify(payload),
|
|
337
|
+
})
|
|
338
|
+
} catch {
|
|
339
|
+
// Reporting must never disturb rendering.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Measure one surface after every render; identical geometry is deduped. */
|
|
344
|
+
function useReportGeometry(part, ref, detail) {
|
|
345
|
+
React.useEffect(() => {
|
|
346
|
+
reportGeometry(part, ref.current, detail)
|
|
347
|
+
})
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** Tell the host which browser build just executed (fire and forget). */
|
|
351
|
+
function announce() {
|
|
352
|
+
try {
|
|
353
|
+
void fetch('/taskflow/client-ready', {
|
|
354
|
+
method: 'POST',
|
|
355
|
+
credentials: 'same-origin',
|
|
356
|
+
headers: { 'content-type': 'application/json' },
|
|
357
|
+
body: JSON.stringify({
|
|
358
|
+
build: CLIENT_BUILD,
|
|
359
|
+
href: String((typeof location !== 'undefined' && location.href) || ''),
|
|
360
|
+
ua: String((typeof navigator !== 'undefined' && navigator.userAgent) || ''),
|
|
361
|
+
}),
|
|
362
|
+
})
|
|
363
|
+
} catch {
|
|
364
|
+
// A host without the route is not an error.
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function injectStyles() {
|
|
369
|
+
if (typeof document === 'undefined') return
|
|
370
|
+
// Keyed by build: an HMR swap must replace the stylesheet too. Guarding on
|
|
371
|
+
// "a tag exists" silently kept the FIRST build's CSS forever.
|
|
372
|
+
const existing = document.querySelector('style[data-plugin-css="dsh-plugin-taskflow"]')
|
|
373
|
+
if (existing && existing.dataset.build === CLIENT_BUILD && existing.textContent === CSS) return
|
|
374
|
+
const style = existing || document.createElement('style')
|
|
375
|
+
style.dataset.plugin = ID
|
|
376
|
+
style.dataset.pluginCss = ID
|
|
377
|
+
style.dataset.build = CLIENT_BUILD
|
|
378
|
+
style.textContent = CSS
|
|
379
|
+
if (!existing) document.head.appendChild(style)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/* ------------------------------------------------------------------ *
|
|
383
|
+
* Host state (fetched over the plugin's own HTTP route)
|
|
384
|
+
* ------------------------------------------------------------------ */
|
|
385
|
+
|
|
386
|
+
const EMPTY = Object.freeze({ loaded: false })
|
|
387
|
+
const bySession = new Map()
|
|
388
|
+
const listeners = new Set()
|
|
389
|
+
const inflight = new Map()
|
|
390
|
+
|
|
391
|
+
const store = {
|
|
392
|
+
activeSession: null,
|
|
393
|
+
subscribe(listener) {
|
|
394
|
+
listeners.add(listener)
|
|
395
|
+
return () => listeners.delete(listener)
|
|
396
|
+
},
|
|
397
|
+
snapshot(sessionId) {
|
|
398
|
+
if (!sessionId) return EMPTY
|
|
399
|
+
return bySession.get(sessionId) || EMPTY
|
|
400
|
+
},
|
|
401
|
+
peek(sessionId) {
|
|
402
|
+
return sessionId ? bySession.get(sessionId) || null : null
|
|
403
|
+
},
|
|
404
|
+
setActive(sessionId) {
|
|
405
|
+
if (this.activeSession === sessionId) return
|
|
406
|
+
this.activeSession = sessionId
|
|
407
|
+
if (sessionId) {
|
|
408
|
+
void refresh(sessionId)
|
|
409
|
+
startStream(sessionId)
|
|
410
|
+
} else {
|
|
411
|
+
stopStream()
|
|
412
|
+
}
|
|
413
|
+
this.emit()
|
|
414
|
+
},
|
|
415
|
+
put(sessionId, value) {
|
|
416
|
+
const previous = bySession.get(sessionId)
|
|
417
|
+
bySession.set(sessionId, value)
|
|
418
|
+
if (previous
|
|
419
|
+
&& previous.updatedAt === value.updatedAt
|
|
420
|
+
&& previous.pending === value.pending
|
|
421
|
+
&& previous.lastTurn === value.lastTurn
|
|
422
|
+
&& previous.openTurn === value.openTurn
|
|
423
|
+
&& previous.delegates === value.delegates
|
|
424
|
+
&& previous.segmentCount === value.segmentCount
|
|
425
|
+
&& (previous.latestSegment?.turn ?? null) === (value.latestSegment?.turn ?? null)
|
|
426
|
+
&& previous.tasks?.updatedAt === value.tasks?.updatedAt) return
|
|
427
|
+
this.emit()
|
|
428
|
+
},
|
|
429
|
+
emit() {
|
|
430
|
+
for (const listener of listeners) {
|
|
431
|
+
try {
|
|
432
|
+
listener()
|
|
433
|
+
} catch {
|
|
434
|
+
// A broken subscriber must not stop the others.
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
},
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
let hostAvailable = true
|
|
441
|
+
/** Live SSE subscription for the active session (null when unavailable). */
|
|
442
|
+
let stream = null
|
|
443
|
+
let streamSession = null
|
|
444
|
+
let streamHealthy = false
|
|
445
|
+
let streamRetry = null
|
|
446
|
+
let streamFailures = 0
|
|
447
|
+
|
|
448
|
+
function stopStream() {
|
|
449
|
+
if (stream !== null) {
|
|
450
|
+
try {
|
|
451
|
+
stream.close()
|
|
452
|
+
} catch {
|
|
453
|
+
// already closed
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
stream = null
|
|
457
|
+
streamSession = null
|
|
458
|
+
streamHealthy = false
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Subscribe to host pushes; on failure fall back to polling and retry later. */
|
|
462
|
+
function startStream(sessionId) {
|
|
463
|
+
if (typeof EventSource === 'undefined' || !sessionId) return
|
|
464
|
+
if (stream !== null && streamSession === sessionId) return
|
|
465
|
+
stopStream()
|
|
466
|
+
streamSession = sessionId
|
|
467
|
+
let source
|
|
468
|
+
try {
|
|
469
|
+
source = new EventSource(`/taskflow/events?session=${encodeURIComponent(sessionId)}`)
|
|
470
|
+
} catch {
|
|
471
|
+
streamSession = null
|
|
472
|
+
return
|
|
473
|
+
}
|
|
474
|
+
stream = source
|
|
475
|
+
source.addEventListener('state', (event) => {
|
|
476
|
+
try {
|
|
477
|
+
const data = JSON.parse(event.data)
|
|
478
|
+
if (!data || data.ok !== true) return
|
|
479
|
+
streamHealthy = true
|
|
480
|
+
streamFailures = 0
|
|
481
|
+
store.put(sessionId, { ...data, loaded: true, fetchedAt: Date.now() })
|
|
482
|
+
} catch {
|
|
483
|
+
// ignore malformed frame
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
source.addEventListener('error', () => {
|
|
487
|
+
streamHealthy = false
|
|
488
|
+
try {
|
|
489
|
+
source.close()
|
|
490
|
+
} catch {
|
|
491
|
+
// ignore
|
|
492
|
+
}
|
|
493
|
+
if (stream === source) {
|
|
494
|
+
stream = null
|
|
495
|
+
streamSession = null
|
|
496
|
+
}
|
|
497
|
+
streamFailures += 1
|
|
498
|
+
if (streamRetry !== null) window.clearTimeout(streamRetry)
|
|
499
|
+
// Give up after six attempts and stay on polling; a later refresh or
|
|
500
|
+
// session switch retries from scratch.
|
|
501
|
+
if (streamFailures > 6) return
|
|
502
|
+
const delay = Math.min(30000, 2000 * 2 ** (streamFailures - 1))
|
|
503
|
+
streamRetry = window.setTimeout(() => {
|
|
504
|
+
streamRetry = null
|
|
505
|
+
const active = store.activeSession
|
|
506
|
+
if (active) startStream(active)
|
|
507
|
+
}, delay)
|
|
508
|
+
})
|
|
509
|
+
}
|
|
510
|
+
/** Opens the sidebar tab that lists every segment summary. */
|
|
511
|
+
let openSidebar = null
|
|
512
|
+
/** Which task's detail popover is open; module scope so remounts keep it. */
|
|
513
|
+
const uiState = { detail: null }
|
|
514
|
+
|
|
515
|
+
/** Fetch one session's TaskFlow state; failures degrade the UI, never throw. */
|
|
516
|
+
async function refresh(sessionId) {
|
|
517
|
+
if (!sessionId || !hostAvailable) return
|
|
518
|
+
if (inflight.has(sessionId)) return inflight.get(sessionId)
|
|
519
|
+
const task = (async () => {
|
|
520
|
+
try {
|
|
521
|
+
const response = await fetch(`/taskflow/state?session=${encodeURIComponent(sessionId)}`, {
|
|
522
|
+
credentials: 'same-origin',
|
|
523
|
+
headers: { accept: 'application/json' },
|
|
524
|
+
})
|
|
525
|
+
if (!response.ok) {
|
|
526
|
+
if (response.status === 404 || response.status === 401) hostAvailable = false
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
const data = await response.json()
|
|
530
|
+
if (!data || data.ok !== true) return
|
|
531
|
+
store.put(sessionId, { ...data, loaded: true, fetchedAt: Date.now() })
|
|
532
|
+
} catch {
|
|
533
|
+
// Offline host (desktop file://) or the route is not mounted: keep the
|
|
534
|
+
// last snapshot and stay quiet.
|
|
535
|
+
} finally {
|
|
536
|
+
inflight.delete(sessionId)
|
|
537
|
+
}
|
|
538
|
+
})()
|
|
539
|
+
inflight.set(sessionId, task)
|
|
540
|
+
return task
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Ask the host to summarize the newest turn that has no segment yet. */
|
|
544
|
+
async function requestSummary(sessionId) {
|
|
545
|
+
try {
|
|
546
|
+
const response = await fetch('/taskflow/summarize', {
|
|
547
|
+
method: 'POST',
|
|
548
|
+
credentials: 'same-origin',
|
|
549
|
+
headers: { 'content-type': 'application/json' },
|
|
550
|
+
body: JSON.stringify({ session: sessionId }),
|
|
551
|
+
})
|
|
552
|
+
const data = await response.json().catch(() => null)
|
|
553
|
+
window.setTimeout(() => void refresh(sessionId), 400)
|
|
554
|
+
return { ok: response.ok && data?.ok === true, reason: data?.reason ?? data?.error ?? '' }
|
|
555
|
+
} catch (error) {
|
|
556
|
+
return { ok: false, reason: String(error?.message ?? error) }
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
let poller = null
|
|
561
|
+
/**
|
|
562
|
+
* Self-rescheduling poll. A running turn needs near-real-time status
|
|
563
|
+
* (paused / subagent / summary flags), an idle conversation does not.
|
|
564
|
+
*/
|
|
565
|
+
function startPoller() {
|
|
566
|
+
if (poller !== null || typeof window === 'undefined') return
|
|
567
|
+
poller = true
|
|
568
|
+
const tick = () => {
|
|
569
|
+
if (poller === null) return
|
|
570
|
+
const hidden = typeof document !== 'undefined' && document.visibilityState === 'hidden'
|
|
571
|
+
const sessionId = store.activeSession
|
|
572
|
+
if (!hidden && sessionId) void refresh(sessionId)
|
|
573
|
+
const snapshot = sessionId ? store.peek(sessionId) : null
|
|
574
|
+
const busy = snapshot ? (typeof snapshot.openTurn === 'number' || snapshot.pending === true) : false
|
|
575
|
+
// A healthy push stream makes polling a pure safety net.
|
|
576
|
+
const next = streamHealthy ? 8000 : (busy ? POLL_MS_ACTIVE : POLL_MS_IDLE)
|
|
577
|
+
window.setTimeout(tick, next)
|
|
578
|
+
}
|
|
579
|
+
window.setTimeout(tick, POLL_MS_ACTIVE)
|
|
580
|
+
// Coming back to the window should not wait for the next tick.
|
|
581
|
+
if (typeof document !== 'undefined') {
|
|
582
|
+
document.addEventListener('visibilitychange', () => {
|
|
583
|
+
if (document.visibilityState === 'visible' && store.activeSession) void refresh(store.activeSession)
|
|
584
|
+
})
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/* ------------------------------------------------------------------ *
|
|
589
|
+
* React helpers
|
|
590
|
+
* ------------------------------------------------------------------ */
|
|
591
|
+
|
|
592
|
+
function useSnapshot(sessionId) {
|
|
593
|
+
return React.useSyncExternalStore(
|
|
594
|
+
store.subscribe,
|
|
595
|
+
() => store.snapshot(sessionId),
|
|
596
|
+
() => store.snapshot(sessionId),
|
|
597
|
+
)
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function useNow(enabled) {
|
|
601
|
+
const [now, setNow] = React.useState(() => Date.now())
|
|
602
|
+
React.useEffect(() => {
|
|
603
|
+
if (!enabled) return undefined
|
|
604
|
+
const timer = window.setInterval(() => setNow(Date.now()), 1000)
|
|
605
|
+
return () => window.clearInterval(timer)
|
|
606
|
+
}, [enabled])
|
|
607
|
+
return now
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** Bound a one-line label, counting every character (CJK included) as one. */
|
|
611
|
+
function clip(value, limit) {
|
|
612
|
+
const text = String(value ?? '').replace(/\s+/g, ' ').trim()
|
|
613
|
+
return text.length <= limit ? text : `${text.slice(0, limit - 1)}…`
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** Local wall-clock time for a timestamp, e.g. 09:12:33. */
|
|
617
|
+
function fmtClock(timestamp) {
|
|
618
|
+
try {
|
|
619
|
+
return new Date(timestamp).toLocaleTimeString('zh-CN', { hour12: false })
|
|
620
|
+
} catch {
|
|
621
|
+
return '--:--:--'
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function fmtDuration(ms) {
|
|
626
|
+
const total = Math.max(0, Math.round((Number(ms) || 0) / 1000))
|
|
627
|
+
if (total < 60) return `${total}s`
|
|
628
|
+
const minutes = Math.floor(total / 60)
|
|
629
|
+
const seconds = total % 60
|
|
630
|
+
if (minutes < 60) return seconds === 0 ? `${minutes}m` : `${minutes}m${seconds}s`
|
|
631
|
+
const hours = Math.floor(minutes / 60)
|
|
632
|
+
const rest = minutes % 60
|
|
633
|
+
return rest === 0 ? `${hours}h` : `${hours}h${rest}m`
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function statusGlyph(status) {
|
|
637
|
+
if (status === 'completed') return '✓'
|
|
638
|
+
if (status === 'in_progress') return '◐'
|
|
639
|
+
return '○'
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* The list to show. DSH clears the `todos` projection on every turn start, so
|
|
644
|
+
* fall back to the host's last recorded snapshot to keep the list visible
|
|
645
|
+
* between turns instead of blanking it.
|
|
646
|
+
*/
|
|
647
|
+
function currentTodos(projection) {
|
|
648
|
+
// Only the live turn's list belongs above the composer. DSH clears the
|
|
649
|
+
// `todos` projection on every turn start, which is exactly the moment the
|
|
650
|
+
// previous round's list should fold back into that round's transcript.
|
|
651
|
+
return Array.isArray(projection) ? projection : []
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** Per-task timing for the current list, merging projection + host timings. */
|
|
655
|
+
function mergeTasks(todos, snapshot, now = Date.now()) {
|
|
656
|
+
const items = snapshot?.tasks?.items ?? {}
|
|
657
|
+
return (todos || []).map((todo) => {
|
|
658
|
+
const timing = items[todo.content]
|
|
659
|
+
let elapsed = null
|
|
660
|
+
if (todo.status === 'completed' && timing?.startedAt && timing?.completedAt) elapsed = timing.completedAt - timing.startedAt
|
|
661
|
+
else if (todo.status === 'in_progress' && timing?.pausedAt && timing?.startedAt && !snapshot?.delegates) elapsed = timing.pausedAt - timing.startedAt
|
|
662
|
+
else if (todo.status === 'in_progress' && timing?.startedAt) elapsed = now - timing.startedAt
|
|
663
|
+
return { ...todo, elapsed }
|
|
664
|
+
})
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/* ------------------------------------------------------------------ *
|
|
668
|
+
* Dock strip
|
|
669
|
+
* ------------------------------------------------------------------ */
|
|
670
|
+
|
|
671
|
+
/**
|
|
672
|
+
* Per-surface error boundary: a render error blanks that surface and is
|
|
673
|
+
* reported, then retried once after a pause. Without it, one bad frame
|
|
674
|
+
* unmounts the whole plugin entry and the user sees "nothing happens".
|
|
675
|
+
*/
|
|
676
|
+
class SurfaceBoundary extends React.Component {
|
|
677
|
+
constructor(props) {
|
|
678
|
+
super(props)
|
|
679
|
+
this.state = { failed: false }
|
|
680
|
+
this.timer = null
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
static getDerivedStateFromError() {
|
|
684
|
+
return { failed: true }
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
componentDidCatch(error) {
|
|
688
|
+
reportGeometry('crash', null, `${this.props.part}: ${String(error?.message ?? error)}`)
|
|
689
|
+
if (this.timer) window.clearTimeout(this.timer)
|
|
690
|
+
this.timer = window.setTimeout(() => this.setState({ failed: false }), 5000)
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
componentWillUnmount() {
|
|
694
|
+
if (this.timer) window.clearTimeout(this.timer)
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
render() {
|
|
698
|
+
return this.state.failed ? null : this.props.children
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const guarded = (part, Component) => (props) => h(SurfaceBoundary, { part }, h(Component, props))
|
|
703
|
+
|
|
704
|
+
const DOCK_MODE_KEY = 'taskflow.dockMode'
|
|
705
|
+
const dockModeListeners = new Set()
|
|
706
|
+
|
|
707
|
+
/** 'custom' (our panel) or 'builtin' (DSH's own panel). */
|
|
708
|
+
function readDockMode() {
|
|
709
|
+
try {
|
|
710
|
+
if (typeof window !== 'undefined' && typeof window.__DSH_TASKFLOW_REPLACE_DOCK__ === 'boolean') {
|
|
711
|
+
return window.__DSH_TASKFLOW_REPLACE_DOCK__ ? 'custom' : 'builtin'
|
|
712
|
+
}
|
|
713
|
+
const stored = window.localStorage?.getItem(DOCK_MODE_KEY)
|
|
714
|
+
if (stored === 'custom' || stored === 'builtin') return stored
|
|
715
|
+
} catch {
|
|
716
|
+
// storage unavailable: fall through to the default
|
|
717
|
+
}
|
|
718
|
+
return DEFAULT_DOCK_MODE
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
function writeDockMode(mode) {
|
|
722
|
+
try {
|
|
723
|
+
window.localStorage?.setItem(DOCK_MODE_KEY, mode)
|
|
724
|
+
} catch {
|
|
725
|
+
// ignore
|
|
726
|
+
}
|
|
727
|
+
for (const listener of dockModeListeners) {
|
|
728
|
+
try {
|
|
729
|
+
listener(mode)
|
|
730
|
+
} catch {
|
|
731
|
+
// ignore
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/** Crisp progress ring: an SVG stroke fills with the done ratio. */
|
|
737
|
+
function ProgressRing(props) {
|
|
738
|
+
const size = props.size || 14
|
|
739
|
+
const stroke = 1.7
|
|
740
|
+
const radius = (size - stroke) / 2
|
|
741
|
+
const circumference = 2 * Math.PI * radius
|
|
742
|
+
const ratio = Math.max(0, Math.min(1, Number(props.ratio) || 0))
|
|
743
|
+
return h('svg', {
|
|
744
|
+
width: size,
|
|
745
|
+
height: size,
|
|
746
|
+
viewBox: `0 0 ${size} ${size}`,
|
|
747
|
+
className: 'tf-ring-svg',
|
|
748
|
+
'aria-hidden': true,
|
|
749
|
+
},
|
|
750
|
+
h('circle', {
|
|
751
|
+
cx: size / 2,
|
|
752
|
+
cy: size / 2,
|
|
753
|
+
r: radius,
|
|
754
|
+
fill: 'none',
|
|
755
|
+
stroke: 'currentColor',
|
|
756
|
+
'stroke-opacity': 0.16,
|
|
757
|
+
'stroke-width': stroke,
|
|
758
|
+
}),
|
|
759
|
+
h('circle', {
|
|
760
|
+
cx: size / 2,
|
|
761
|
+
cy: size / 2,
|
|
762
|
+
r: radius,
|
|
763
|
+
fill: 'none',
|
|
764
|
+
stroke: 'currentColor',
|
|
765
|
+
'stroke-opacity': 0.85,
|
|
766
|
+
'stroke-width': stroke,
|
|
767
|
+
'stroke-linecap': 'round',
|
|
768
|
+
'stroke-dasharray': `${circumference} ${circumference}`,
|
|
769
|
+
'stroke-dashoffset': `${circumference * (1 - ratio)}`,
|
|
770
|
+
transform: `rotate(-90 ${size / 2} ${size / 2})`,
|
|
771
|
+
style: { transition: 'stroke-dashoffset .45s cubic-bezier(.4,0,.2,1)' },
|
|
772
|
+
}))
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function TaskStrip(props) {
|
|
776
|
+
const sessionId = props.sessionId
|
|
777
|
+
const snapshot = useSnapshot(sessionId)
|
|
778
|
+
const todos = props.useProjection ? props.useProjection('todos') : undefined
|
|
779
|
+
const projectionList = currentTodos(todos)
|
|
780
|
+
// Compatibility fallback: if the shipped `todos` projection is missing (or
|
|
781
|
+
// renamed in a future DSH), the host's own list for the running turn keeps
|
|
782
|
+
// the panel working.
|
|
783
|
+
const hostList = snapshot.currentListTurn !== null
|
|
784
|
+
&& snapshot.currentListTurn !== undefined
|
|
785
|
+
&& snapshot.currentListTurn === snapshot.openTurn
|
|
786
|
+
? (snapshot.currentList || [])
|
|
787
|
+
: []
|
|
788
|
+
const list = projectionList.length > 0 ? projectionList : hostList
|
|
789
|
+
const [collapsed, setCollapsed] = React.useState(props.compact === true)
|
|
790
|
+
const [showAllDone, setShowAllDone] = React.useState(false)
|
|
791
|
+
const [detail, setDetailState] = React.useState(uiState.detail)
|
|
792
|
+
const [detailItem, setDetailItem] = React.useState(null)
|
|
793
|
+
const setDetail = (value) => {
|
|
794
|
+
uiState.detail = value
|
|
795
|
+
setDetailState(value)
|
|
796
|
+
}
|
|
797
|
+
const rootRef = React.useRef(null)
|
|
798
|
+
const liveLayout = React.useRef(null)
|
|
799
|
+
const detailRef = React.useRef(null)
|
|
800
|
+
const detailAnchor = React.useRef(null)
|
|
801
|
+
|
|
802
|
+
React.useEffect(() => {
|
|
803
|
+
store.setActive(sessionId)
|
|
804
|
+
}, [sessionId])
|
|
805
|
+
|
|
806
|
+
// The projection changes exactly when the agent writes the list, so pull
|
|
807
|
+
// our own state at that moment instead of waiting for the next poll.
|
|
808
|
+
React.useEffect(() => {
|
|
809
|
+
if (sessionId) void refresh(sessionId)
|
|
810
|
+
}, [sessionId, todos])
|
|
811
|
+
|
|
812
|
+
// The list and its detail only change on user action. Nothing auto-opens:
|
|
813
|
+
// switching conversations must not pop a panel in the user's face.
|
|
814
|
+
|
|
815
|
+
const turnOpen = typeof snapshot.openTurn === 'number'
|
|
816
|
+
const delegated = Number(snapshot.delegates) > 0
|
|
817
|
+
// A subagent still working means the work is NOT paused, even though this
|
|
818
|
+
// turn is over.
|
|
819
|
+
const waiting = !turnOpen && !delegated
|
|
820
|
+
const active = list.find((todo) => todo.status === 'in_progress')
|
|
821
|
+
const running = list.filter((todo) => todo.status === 'in_progress').length
|
|
822
|
+
const parallel = running > 1
|
|
823
|
+
const total = list.length
|
|
824
|
+
const done = list.filter((todo) => todo.status === 'completed').length
|
|
825
|
+
const segmentCount = Number(snapshot.segmentCount) || 0
|
|
826
|
+
const latest = snapshot.latestSegment || null
|
|
827
|
+
const layoutNote = liveLayout.current ? JSON.stringify(liveLayout.current) : ''
|
|
828
|
+
const ratio = total === 0 ? 0 : Math.round((done / total) * 100)
|
|
829
|
+
|
|
830
|
+
// Follow the composer card: inside the composer wrapper, the widest element
|
|
831
|
+
// narrower than the wrapper itself is the rounded input card. Copy its
|
|
832
|
+
// width and left edge so the list sits exactly under the input.
|
|
833
|
+
React.useEffect(() => {
|
|
834
|
+
const element = rootRef.current
|
|
835
|
+
const parent = element ? element.parentElement : null
|
|
836
|
+
const stack = parent ? parent.parentElement : null
|
|
837
|
+
if (!element || !parent || !stack || typeof window === 'undefined') return undefined
|
|
838
|
+
let observer = null
|
|
839
|
+
let aligned = null
|
|
840
|
+
const align = () => {
|
|
841
|
+
const stackRect = stack.getBoundingClientRect()
|
|
842
|
+
if (stackRect.width === 0) return
|
|
843
|
+
// Anchor on the input editor and walk up: the last ancestor narrower
|
|
844
|
+
// than the composer stack is the rounded input card.
|
|
845
|
+
const editor = stack.querySelector('[contenteditable="true"]')
|
|
846
|
+
|| document.querySelector('[contenteditable="true"]')
|
|
847
|
+
let card = null
|
|
848
|
+
let node = editor
|
|
849
|
+
while (node && node !== stack && node !== document.body) {
|
|
850
|
+
const rect = node.getBoundingClientRect()
|
|
851
|
+
if (rect.width > 0 && rect.width < stackRect.width - 1) {
|
|
852
|
+
card = { width: rect.width, left: rect.left }
|
|
853
|
+
}
|
|
854
|
+
node = node.parentElement
|
|
855
|
+
}
|
|
856
|
+
if (!card) return
|
|
857
|
+
element.style.position = 'relative'
|
|
858
|
+
element.style.left = '0px'
|
|
859
|
+
element.style.width = `${Math.round(card.width)}px`
|
|
860
|
+
const current = element.getBoundingClientRect()
|
|
861
|
+
const offset = Math.max(0, Math.round(card.left - current.left))
|
|
862
|
+
element.style.left = `${offset}px`
|
|
863
|
+
liveLayout.current = {
|
|
864
|
+
width: Math.round(card.width),
|
|
865
|
+
offset,
|
|
866
|
+
cardLeft: Math.round(card.left),
|
|
867
|
+
staticLeft: Math.round(current.left),
|
|
868
|
+
editor: editor ? Math.round(editor.getBoundingClientRect().width) : null,
|
|
869
|
+
}
|
|
870
|
+
reportGeometry('dock', element, `aligned ${JSON.stringify(liveLayout.current)}`)
|
|
871
|
+
}
|
|
872
|
+
align()
|
|
873
|
+
// The composer may mount after this dock entry, so retry briefly.
|
|
874
|
+
const retries = [200, 700, 1500, 3000].map((delay) => window.setTimeout(align, delay))
|
|
875
|
+
window.addEventListener('resize', align)
|
|
876
|
+
try {
|
|
877
|
+
if (typeof ResizeObserver !== 'undefined') {
|
|
878
|
+
observer = new ResizeObserver(align)
|
|
879
|
+
observer.observe(stack)
|
|
880
|
+
}
|
|
881
|
+
} catch {
|
|
882
|
+
observer = null
|
|
883
|
+
}
|
|
884
|
+
return () => {
|
|
885
|
+
for (const timer of retries) window.clearTimeout(timer)
|
|
886
|
+
window.removeEventListener('resize', align)
|
|
887
|
+
if (observer) observer.disconnect()
|
|
888
|
+
}
|
|
889
|
+
}, [total, collapsed])
|
|
890
|
+
|
|
891
|
+
useReportGeometry('dock', rootRef, `${collapsed ? 'collapsed' : 'open'} / ${total} tasks / ${segmentCount} segments / sse:${streamHealthy ? 'live' : 'poll'}${layoutNote ? ` / ${layoutNote}` : ''}`)
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
useReportGeometry('detail', detailRef, detail ? detail.content : 'closed')
|
|
896
|
+
|
|
897
|
+
// Open one task's detail; the anchor is the row that was clicked.
|
|
898
|
+
const openDetail = (content, element) => {
|
|
899
|
+
detailAnchor.current = element || null
|
|
900
|
+
setDetail({ content, sessionId })
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// While a detail is open: close on outside click / Esc, follow the anchor.
|
|
904
|
+
React.useEffect(() => {
|
|
905
|
+
if (!detail) return undefined
|
|
906
|
+
const onPointerDown = (event) => {
|
|
907
|
+
if (detailRef.current && detailRef.current.contains(event.target)) return
|
|
908
|
+
if (detailAnchor.current && detailAnchor.current.contains(event.target)) return
|
|
909
|
+
setDetail(null)
|
|
910
|
+
}
|
|
911
|
+
const onKeyDown = (event) => {
|
|
912
|
+
if (event.key === 'Escape') setDetail(null)
|
|
913
|
+
}
|
|
914
|
+
document.addEventListener('mousedown', onPointerDown, true)
|
|
915
|
+
document.addEventListener('keydown', onKeyDown)
|
|
916
|
+
return () => {
|
|
917
|
+
document.removeEventListener('mousedown', onPointerDown, true)
|
|
918
|
+
document.removeEventListener('keydown', onKeyDown)
|
|
919
|
+
}
|
|
920
|
+
}, [detail])
|
|
921
|
+
|
|
922
|
+
// Steps and files are no longer carried in the poll frame: pull them when
|
|
923
|
+
// the popover opens.
|
|
924
|
+
React.useEffect(() => {
|
|
925
|
+
if (!detail || !detail.content) {
|
|
926
|
+
setDetailItem(null)
|
|
927
|
+
return undefined
|
|
928
|
+
}
|
|
929
|
+
let cancelled = false
|
|
930
|
+
const load = async () => {
|
|
931
|
+
try {
|
|
932
|
+
const response = await fetch(`/taskflow/task?session=${encodeURIComponent(detail.sessionId || store.activeSession || '')}&content=${encodeURIComponent(detail.content)}`, {
|
|
933
|
+
credentials: 'same-origin',
|
|
934
|
+
headers: { accept: 'application/json' },
|
|
935
|
+
})
|
|
936
|
+
if (!response.ok) return
|
|
937
|
+
const data = await response.json()
|
|
938
|
+
if (!cancelled && data && data.ok) setDetailItem(data.item)
|
|
939
|
+
} catch {
|
|
940
|
+
// keep the light item as a fallback
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
void load()
|
|
944
|
+
return () => {
|
|
945
|
+
cancelled = true
|
|
946
|
+
}
|
|
947
|
+
}, [detail?.content])
|
|
948
|
+
|
|
949
|
+
// Nothing at all yet: still show that the plugin is here, so every
|
|
950
|
+
// conversation has the same anchor instead of silently disappearing.
|
|
951
|
+
// While a turn is open, say "planning" instead of "nothing here" — the
|
|
952
|
+
// list is about to arrive, and the user should see that immediately.
|
|
953
|
+
if (total === 0 && !latest) {
|
|
954
|
+
return h('div', { className: 'tf-float', ref: rootRef },
|
|
955
|
+
h('div', { className: 'tf-inline tf-inline-empty' },
|
|
956
|
+
h('div', { className: 'tf-inline-head' },
|
|
957
|
+
turnOpen
|
|
958
|
+
? h('span', { className: 'tf-dot', 'aria-hidden': true })
|
|
959
|
+
: h('span', { className: 'tf-empty-dot', 'aria-hidden': true }, '◌'),
|
|
960
|
+
h('span', { className: 'tf-inline-current' },
|
|
961
|
+
turnOpen ? t('panel.planning') : t('panel.emptySession')))))
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// No task list: fall back to the one-line summary pill (click -> sidebar).
|
|
965
|
+
if (total === 0) {
|
|
966
|
+
if (!latest) return null
|
|
967
|
+
return h('div', { className: 'tf-float', ref: rootRef },
|
|
968
|
+
h('div', { className: 'tf-pill', 'data-kind': 'summary' },
|
|
969
|
+
h('button', {
|
|
970
|
+
type: 'button',
|
|
971
|
+
className: 'tf-ring',
|
|
972
|
+
title: t('strip.sidebar'),
|
|
973
|
+
'aria-label': t('strip.sidebar'),
|
|
974
|
+
onClick: () => {
|
|
975
|
+
if (typeof openSidebar === 'function') openSidebar()
|
|
976
|
+
},
|
|
977
|
+
}, h(ProgressRing, { ratio: total === 0 ? 1 : done / total, size: 14 })),
|
|
978
|
+
h('button', {
|
|
979
|
+
type: 'button',
|
|
980
|
+
className: 'tf-pill-main',
|
|
981
|
+
title: `${latest.title} · ${t('strip.open')}`,
|
|
982
|
+
'aria-label': t('strip.open'),
|
|
983
|
+
onClick: () => {
|
|
984
|
+
if (typeof openSidebar === 'function') openSidebar()
|
|
985
|
+
},
|
|
986
|
+
},
|
|
987
|
+
h('span', { className: 'tf-pill-text' }, clip(latest.title || '', 20)))))
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
const merged = mergeTasks(list, snapshot, Date.now())
|
|
991
|
+
const finished = merged.filter((todo) => todo.status === 'completed')
|
|
992
|
+
const hiddenDone = showAllDone ? 0 : Math.max(0, finished.length - 3)
|
|
993
|
+
const visible = hiddenDone === 0
|
|
994
|
+
? merged
|
|
995
|
+
: merged.filter((todo) => todo.status !== 'completed' || finished.indexOf(todo) >= hiddenDone)
|
|
996
|
+
const moreRow = hiddenDone > 0
|
|
997
|
+
? h('li', { key: '__more', className: 'tf-item tf-item-more' },
|
|
998
|
+
h('button', {
|
|
999
|
+
type: 'button',
|
|
1000
|
+
className: 'tf-item-btn',
|
|
1001
|
+
'data-more': 'true',
|
|
1002
|
+
onClick: () => setShowAllDone(true),
|
|
1003
|
+
}, h('span', { className: 'tf-item-text' }, t('tasks.moreDone', { count: hiddenDone }))))
|
|
1004
|
+
: null
|
|
1005
|
+
|
|
1006
|
+
const rows = visible.map((todo) => {
|
|
1007
|
+
const item = snapshot.tasks?.items?.[todo.content] || {}
|
|
1008
|
+
const summary = typeof item.summary === 'string' ? item.summary : ''
|
|
1009
|
+
const hint = summary
|
|
1010
|
+
? ''
|
|
1011
|
+
: item.summaryPending
|
|
1012
|
+
? t('tasks.summary.pending')
|
|
1013
|
+
: todo.status === 'completed' ? t('tasks.summary.none') : ''
|
|
1014
|
+
const body = [
|
|
1015
|
+
h('span', { className: 'tf-item-mark', 'data-state': todo.status, 'aria-hidden': true },
|
|
1016
|
+
todo.status === 'in_progress'
|
|
1017
|
+
? (waiting ? '⏸' : h('span', { className: 'tf-dot' }))
|
|
1018
|
+
: statusGlyph(todo.status)),
|
|
1019
|
+
h('span', { className: 'tf-item-text' },
|
|
1020
|
+
parallel && todo.status === 'in_progress' ? h('span', { className: 'tf-item-parallel' }, '∥ ') : null,
|
|
1021
|
+
todo.content),
|
|
1022
|
+
hint ? h('span', { className: 'tf-item-hint' }, hint) : null,
|
|
1023
|
+
todo.elapsed !== null ? h('span', { className: 'tf-item-time' }, fmtDuration(todo.elapsed)) : null,
|
|
1024
|
+
summary ? h('span', { className: 'tf-item-caret', 'aria-hidden': true }, '›') : null,
|
|
1025
|
+
]
|
|
1026
|
+
return h('li', { key: todo.content, className: 'tf-item' },
|
|
1027
|
+
summary
|
|
1028
|
+
? h('button', {
|
|
1029
|
+
type: 'button',
|
|
1030
|
+
className: 'tf-item-btn',
|
|
1031
|
+
'data-status': todo.status,
|
|
1032
|
+
'data-active': detail && detail.content === todo.content ? 'true' : 'false',
|
|
1033
|
+
title: summary,
|
|
1034
|
+
onClick: (event) => openDetail(todo.content, event.currentTarget),
|
|
1035
|
+
}, body)
|
|
1036
|
+
: h('div', { className: 'tf-item-btn', 'data-status': todo.status, 'data-static': 'true' }, body))
|
|
1037
|
+
})
|
|
1038
|
+
|
|
1039
|
+
const detailTiming = detail
|
|
1040
|
+
? { ...(snapshot.tasks?.items?.[detail.content] || {}), ...(detailItem || {}) }
|
|
1041
|
+
: {}
|
|
1042
|
+
const detailElapsed = (() => {
|
|
1043
|
+
if (detailTiming.startedAt && detailTiming.completedAt) return detailTiming.completedAt - detailTiming.startedAt
|
|
1044
|
+
if (detailTiming.startedAt) return Date.now() - detailTiming.startedAt
|
|
1045
|
+
return null
|
|
1046
|
+
})()
|
|
1047
|
+
|
|
1048
|
+
const detailPopover = detail && ReactDOM && typeof ReactDOM.createPortal === 'function'
|
|
1049
|
+
? ReactDOM.createPortal(h('div', {
|
|
1050
|
+
className: 'tf-detail',
|
|
1051
|
+
ref: detailRef,
|
|
1052
|
+
style: (() => {
|
|
1053
|
+
const anchor = detailAnchor.current
|
|
1054
|
+
const rect = anchor && anchor.getBoundingClientRect ? anchor.getBoundingClientRect() : null
|
|
1055
|
+
if (!rect) return { right: '16px', bottom: '120px', width: '320px' }
|
|
1056
|
+
const width = Math.min(340, Math.max(240, window.innerWidth - 32))
|
|
1057
|
+
const left = Math.min(Math.max(12, rect.right - width), window.innerWidth - width - 12)
|
|
1058
|
+
const below = rect.bottom + 8
|
|
1059
|
+
const above = rect.top - 8
|
|
1060
|
+
return below + 150 < window.innerHeight
|
|
1061
|
+
? { left: `${left}px`, top: `${below}px`, width: `${width}px` }
|
|
1062
|
+
: { left: `${left}px`, bottom: `${Math.max(12, window.innerHeight - above)}px`, width: `${width}px` }
|
|
1063
|
+
})(),
|
|
1064
|
+
},
|
|
1065
|
+
h('div', { className: 'tf-detail-title' }, detail.content),
|
|
1066
|
+
h('div', { className: 'tf-detail-summary' }, detailTiming.summary || ''),
|
|
1067
|
+
detailTiming.startedAt
|
|
1068
|
+
? h('div', { className: 'tf-detail-row' },
|
|
1069
|
+
h('span', { className: 'tf-detail-key' }, t('tasks.detail.start', { time: '' }).replace(/\s+$/, '')),
|
|
1070
|
+
h('span', { className: 'tf-detail-val' }, fmtClock(detailTiming.startedAt)))
|
|
1071
|
+
: null,
|
|
1072
|
+
detailTiming.completedAt
|
|
1073
|
+
? h('div', { className: 'tf-detail-row' },
|
|
1074
|
+
h('span', { className: 'tf-detail-key' }, t('tasks.detail.done', { time: '' }).replace(/\s+$/, '')),
|
|
1075
|
+
h('span', { className: 'tf-detail-val' }, fmtClock(detailTiming.completedAt)))
|
|
1076
|
+
: null,
|
|
1077
|
+
detailElapsed !== null
|
|
1078
|
+
? h('div', { className: 'tf-detail-row' },
|
|
1079
|
+
h('span', { className: 'tf-detail-key' }, t('tasks.detail.spent', { value: '' }).replace(/\s+$/, '')),
|
|
1080
|
+
h('span', { className: 'tf-detail-val' }, fmtDuration(detailElapsed)))
|
|
1081
|
+
: null,
|
|
1082
|
+
Array.isArray(detailTiming.steps) && detailTiming.steps.length > 0
|
|
1083
|
+
? h('div', { className: 'tf-detail-sec' },
|
|
1084
|
+
h('div', { className: 'tf-detail-key' }, t('tasks.detail.steps')),
|
|
1085
|
+
h('ul', { className: 'tf-detail-list' },
|
|
1086
|
+
detailTiming.steps.slice(-6).map((step, index) => h('li', { key: index },
|
|
1087
|
+
`${step.n}${step.a ? ` · ${step.a}` : ''}`))))
|
|
1088
|
+
: null,
|
|
1089
|
+
Array.isArray(detailTiming.files) && detailTiming.files.length > 0
|
|
1090
|
+
? h('div', { className: 'tf-detail-sec' },
|
|
1091
|
+
h('div', { className: 'tf-detail-key' }, t('tasks.detail.files')),
|
|
1092
|
+
h('ul', { className: 'tf-detail-list' },
|
|
1093
|
+
detailTiming.files.slice(0, 6).map((file, index) => h('li', { key: index }, file))))
|
|
1094
|
+
: null), document.body)
|
|
1095
|
+
: null
|
|
1096
|
+
|
|
1097
|
+
return h(React.Fragment, null,
|
|
1098
|
+
h('div', {
|
|
1099
|
+
className: 'tf-inline',
|
|
1100
|
+
ref: rootRef,
|
|
1101
|
+
'data-running': active ? 'true' : 'false',
|
|
1102
|
+
},
|
|
1103
|
+
h('div', {
|
|
1104
|
+
className: 'tf-inline-head',
|
|
1105
|
+
'data-state': active && waiting ? 'waiting' : (active && delegated ? 'delegated' : (total > 0 && done === total ? 'done' : (active ? 'running' : 'idle'))),
|
|
1106
|
+
},
|
|
1107
|
+
h('button', {
|
|
1108
|
+
type: 'button',
|
|
1109
|
+
className: 'tf-ring',
|
|
1110
|
+
title: t('strip.sidebar'),
|
|
1111
|
+
'aria-label': t('strip.sidebar'),
|
|
1112
|
+
onClick: () => {
|
|
1113
|
+
if (typeof openSidebar === 'function') openSidebar()
|
|
1114
|
+
},
|
|
1115
|
+
}, h(ProgressRing, { ratio: total === 0 ? 1 : done / total, size: 14 })),
|
|
1116
|
+
h('span', { className: 'tf-inline-count' }, `${done}/${total}`),
|
|
1117
|
+
h('span', { className: 'tf-inline-current' },
|
|
1118
|
+
active
|
|
1119
|
+
? (waiting
|
|
1120
|
+
? `${t('status.waiting')}:${clip(active.content, 30)}`
|
|
1121
|
+
: (delegated ? `${t('status.delegated')}:${clip(active.content, 30)}` : active.content))
|
|
1122
|
+
: (done === total ? t('status.allDone') : '')),
|
|
1123
|
+
h('button', {
|
|
1124
|
+
type: 'button',
|
|
1125
|
+
className: 'tf-inline-toggle',
|
|
1126
|
+
'data-collapsed': collapsed ? 'true' : 'false',
|
|
1127
|
+
'aria-label': collapsed ? t('strip.expand') : t('strip.collapse'),
|
|
1128
|
+
title: collapsed ? t('strip.expand') : t('strip.collapse'),
|
|
1129
|
+
onClick: () => setCollapsed((value) => !value),
|
|
1130
|
+
},
|
|
1131
|
+
h('svg', {
|
|
1132
|
+
className: 'tf-chevron',
|
|
1133
|
+
width: 14,
|
|
1134
|
+
height: 14,
|
|
1135
|
+
viewBox: '0 0 16 16',
|
|
1136
|
+
'aria-hidden': true,
|
|
1137
|
+
}, h('path', {
|
|
1138
|
+
d: 'M4 6l4 4 4-4',
|
|
1139
|
+
fill: 'none',
|
|
1140
|
+
stroke: 'currentColor',
|
|
1141
|
+
'stroke-width': 1.6,
|
|
1142
|
+
'stroke-linecap': 'round',
|
|
1143
|
+
'stroke-linejoin': 'round',
|
|
1144
|
+
})))),
|
|
1145
|
+
collapsed ? null : h('ul', { className: 'tf-inline-list' }, rows)),
|
|
1146
|
+
detailPopover)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
const TaskStripEntry = (props) => h(SurfaceBoundary, { part: 'dock' }, h(TaskStrip, props))
|
|
1150
|
+
|
|
1151
|
+
/* ------------------------------------------------------------------ *
|
|
1152
|
+
* Todo list card in the transcript (replaces the built-in one-liner)
|
|
1153
|
+
* ------------------------------------------------------------------ */
|
|
1154
|
+
|
|
1155
|
+
/** Read a tool call's arguments, tolerating both block shapes. */
|
|
1156
|
+
function callArgs(block) {
|
|
1157
|
+
const raw = block && 'kind' in block ? block.call?.argsRaw : block?.argsRaw
|
|
1158
|
+
if (raw && typeof raw === 'object') return raw
|
|
1159
|
+
if (typeof raw !== 'string' || raw.length === 0) return {}
|
|
1160
|
+
try {
|
|
1161
|
+
return JSON.parse(raw)
|
|
1162
|
+
} catch {
|
|
1163
|
+
return {}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function TaskflowIcon(props) {
|
|
1168
|
+
const size = props?.size ?? 16
|
|
1169
|
+
return h('svg', { width: size, height: size, viewBox: '0 0 16 16', 'aria-hidden': true },
|
|
1170
|
+
h('rect', { x: 1.5, y: 2.5, width: 13, height: 3, rx: 1, fill: 'currentColor', opacity: 0.9 }),
|
|
1171
|
+
h('rect', { x: 1.5, y: 7, width: 9, height: 2, rx: 1, fill: 'currentColor', opacity: 0.6 }),
|
|
1172
|
+
h('rect', { x: 1.5, y: 10.5, width: 6, height: 2, rx: 1, fill: 'currentColor', opacity: 0.4 }))
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function TodoCard(props) {
|
|
1176
|
+
const args = callArgs(props.block)
|
|
1177
|
+
const todos = Array.isArray(args.todos) ? args.todos : []
|
|
1178
|
+
const rootRef = React.useRef(null)
|
|
1179
|
+
const done = todos.filter((todo) => todo.status === 'completed').length
|
|
1180
|
+
const allDone = todos.length > 0 && done === todos.length
|
|
1181
|
+
// Finished rounds collapse to one line inside that round's transcript;
|
|
1182
|
+
// the live list stays open while work is still running.
|
|
1183
|
+
const [open, setOpen] = React.useState(!allDone)
|
|
1184
|
+
useReportGeometry('taskcard', rootRef, `${todos.length} items ${open ? 'open' : 'folded'}`)
|
|
1185
|
+
|
|
1186
|
+
// A call still streaming its arguments renders nothing, so the transcript
|
|
1187
|
+
// never flashes an empty card.
|
|
1188
|
+
if (todos.length === 0) return null
|
|
1189
|
+
|
|
1190
|
+
return h('div', { className: 'tf-tasks', 'data-open': open ? 'true' : 'false', ref: rootRef },
|
|
1191
|
+
h('button', {
|
|
1192
|
+
type: 'button',
|
|
1193
|
+
className: 'tf-tasks-head',
|
|
1194
|
+
'aria-expanded': open,
|
|
1195
|
+
onClick: () => setOpen((value) => !value),
|
|
1196
|
+
},
|
|
1197
|
+
h('span', { className: 'tf-tasks-caret', 'aria-hidden': true }, open ? '▾' : '▸'),
|
|
1198
|
+
h('span', null, t('tasks.header', { total: todos.length, done, open: todos.length - done }))),
|
|
1199
|
+
open ? h('ul', { className: 'tf-tasks-list' }, todos.map((todo, index) => h('li', {
|
|
1200
|
+
key: `${index}-${todo.content}`,
|
|
1201
|
+
className: 'tf-tasks-item',
|
|
1202
|
+
'data-status': todo.status,
|
|
1203
|
+
},
|
|
1204
|
+
h('span', { className: 'tf-tasks-mark', 'aria-hidden': true },
|
|
1205
|
+
todo.status === 'completed' ? '✔' : todo.status === 'in_progress' ? '◐' : '□'),
|
|
1206
|
+
h('span', { className: 'tf-tasks-text' }, todo.content)))) : null)
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
const TodoCardEntry = (props) => h(SurfaceBoundary, { part: 'taskcard' }, h(TodoCard, props))
|
|
1210
|
+
|
|
1211
|
+
/* ------------------------------------------------------------------ *
|
|
1212
|
+
* Floating card content
|
|
1213
|
+
* ------------------------------------------------------------------ */
|
|
1214
|
+
|
|
1215
|
+
function SummaryPanel(props) {
|
|
1216
|
+
const sessionId = props.sessionId
|
|
1217
|
+
const snapshot = useSnapshot(sessionId)
|
|
1218
|
+
const [busy, setBusy] = React.useState(false)
|
|
1219
|
+
const [note, setNote] = React.useState('')
|
|
1220
|
+
const now = useNow(true)
|
|
1221
|
+
const rootRef = React.useRef(null)
|
|
1222
|
+
const [segments, setSegments] = React.useState([])
|
|
1223
|
+
const segmentCount = Number(snapshot.segmentCount) || 0
|
|
1224
|
+
React.useEffect(() => {
|
|
1225
|
+
let cancelled = false
|
|
1226
|
+
const load = async () => {
|
|
1227
|
+
try {
|
|
1228
|
+
const response = await fetch(`/taskflow/segments?session=${encodeURIComponent(sessionId)}`, {
|
|
1229
|
+
credentials: 'same-origin',
|
|
1230
|
+
headers: { accept: 'application/json' },
|
|
1231
|
+
})
|
|
1232
|
+
if (!response.ok) return
|
|
1233
|
+
const data = await response.json()
|
|
1234
|
+
if (!cancelled && data && data.ok) setSegments(data.segments || [])
|
|
1235
|
+
} catch {
|
|
1236
|
+
// keep whatever we already have
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
void load()
|
|
1240
|
+
return () => {
|
|
1241
|
+
cancelled = true
|
|
1242
|
+
}
|
|
1243
|
+
}, [sessionId, segmentCount])
|
|
1244
|
+
useReportGeometry('sidebar', rootRef, `${segments.length} segments`)
|
|
1245
|
+
|
|
1246
|
+
React.useEffect(() => {
|
|
1247
|
+
store.setActive(sessionId)
|
|
1248
|
+
void refresh(sessionId)
|
|
1249
|
+
}, [sessionId])
|
|
1250
|
+
|
|
1251
|
+
const ordered = segments.slice().sort((a, b) => b.turn - a.turn)
|
|
1252
|
+
|
|
1253
|
+
const summarize = async () => {
|
|
1254
|
+
setBusy(true)
|
|
1255
|
+
setNote('')
|
|
1256
|
+
const result = await requestSummary(sessionId)
|
|
1257
|
+
setBusy(false)
|
|
1258
|
+
if (!result.ok) setNote(result.reason || 'FAILED')
|
|
1259
|
+
window.setTimeout(() => void refresh(sessionId), 600)
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
const segmentRows = ordered.map((segment) => h('div', { key: segment.turn, className: 'tf-seg' },
|
|
1263
|
+
h('div', { className: 'tf-seg-head' },
|
|
1264
|
+
h('span', { className: 'tf-seg-title' }, segment.title || t('card.turn', { turn: segment.turn })),
|
|
1265
|
+
h('span', { className: 'tf-seg-meta' },
|
|
1266
|
+
[t('card.turn', { turn: segment.turn }),
|
|
1267
|
+
segment.toolCalls ? t('card.steps', { count: segment.toolCalls }) : '',
|
|
1268
|
+
segment.durationMs ? fmtDuration(segment.durationMs) : ''].filter(Boolean).join(' · '))),
|
|
1269
|
+
Array.isArray(segment.bullets) && segment.bullets.length > 0
|
|
1270
|
+
? h('ul', null, segment.bullets.map((bullet, index) => h('li', { key: index }, bullet)))
|
|
1271
|
+
: null,
|
|
1272
|
+
segment.mechanical ? h('span', { className: 'tf-seg-meta' }, t('card.mechanical')) : null))
|
|
1273
|
+
|
|
1274
|
+
return h('div', { className: 'tf-panel', ref: rootRef },
|
|
1275
|
+
h('div', { className: 'tf-panel-head' },
|
|
1276
|
+
h('span', { className: 'tf-panel-title' }, t('panel.title')),
|
|
1277
|
+
h('span', { className: 'tf-panel-sub' }, t('panel.allSummaries'))),
|
|
1278
|
+
h('div', { className: 'tf-section' },
|
|
1279
|
+
segments.length === 0
|
|
1280
|
+
? h('div', { className: 'tf-empty' }, t('panel.empty'))
|
|
1281
|
+
: segmentRows,
|
|
1282
|
+
h('div', { className: 'tf-body-actions' },
|
|
1283
|
+
h('button', {
|
|
1284
|
+
type: 'button',
|
|
1285
|
+
className: 'tf-btn',
|
|
1286
|
+
disabled: busy,
|
|
1287
|
+
onClick: () => void summarize(),
|
|
1288
|
+
}, busy ? t('panel.summarizing') : t('panel.summarize')),
|
|
1289
|
+
h('span', { className: 'tf-seg-meta' }, `${t('panel.updated')} ${fmtDuration(now - (snapshot.updatedAt || now))}`)),
|
|
1290
|
+
note ? h('div', { className: 'tf-error' }, note) : null))
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
const SummaryPanelEntry = (props) => h(SurfaceBoundary, { part: 'sidebar' }, h(SummaryPanel, props))
|
|
1294
|
+
|
|
1295
|
+
/* ------------------------------------------------------------------ *
|
|
1296
|
+
* Settings page: pick our panel or the shipped one
|
|
1297
|
+
* ------------------------------------------------------------------ */
|
|
1298
|
+
|
|
1299
|
+
function TaskflowSettings() {
|
|
1300
|
+
const [mode, setMode] = React.useState(() => readDockMode())
|
|
1301
|
+
React.useEffect(() => {
|
|
1302
|
+
const listener = (next) => setMode(next)
|
|
1303
|
+
dockModeListeners.add(listener)
|
|
1304
|
+
return () => dockModeListeners.delete(listener)
|
|
1305
|
+
}, [])
|
|
1306
|
+
|
|
1307
|
+
const choose = (next) => {
|
|
1308
|
+
writeDockMode(next)
|
|
1309
|
+
if (typeof applyDockModeRef.current === 'function') applyDockModeRef.current(next)
|
|
1310
|
+
setMode(next)
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
const option = (value, label, hint) => h('button', {
|
|
1314
|
+
type: 'button',
|
|
1315
|
+
className: 'tf-option',
|
|
1316
|
+
'data-selected': mode === value ? 'true' : 'false',
|
|
1317
|
+
onClick: () => choose(value),
|
|
1318
|
+
},
|
|
1319
|
+
h('span', { className: 'tf-option-mark', 'aria-hidden': true }, mode === value ? '◉' : '○'),
|
|
1320
|
+
h('span', { className: 'tf-option-body' },
|
|
1321
|
+
h('span', { className: 'tf-option-title' }, label),
|
|
1322
|
+
h('span', { className: 'tf-option-hint' }, hint)))
|
|
1323
|
+
|
|
1324
|
+
return h('div', { className: 'tf-settings' },
|
|
1325
|
+
h('div', { className: 'tf-settings-head' }, t('settings.title')),
|
|
1326
|
+
h('div', { className: 'tf-settings-sub' }, t('settings.subtitle')),
|
|
1327
|
+
option('custom', t('settings.custom'), t('settings.customHint')),
|
|
1328
|
+
option('builtin', t('settings.builtin'), t('settings.builtinHint')),
|
|
1329
|
+
h('div', { className: 'tf-settings-note' }, t('settings.note')))
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
const applyDockModeRef = { current: null }
|
|
1333
|
+
|
|
1334
|
+
/* ------------------------------------------------------------------ *
|
|
1335
|
+
* Wiring
|
|
1336
|
+
* ------------------------------------------------------------------ */
|
|
1337
|
+
|
|
1338
|
+
/** Run one registration step; a failing surface must not take the others down. */
|
|
1339
|
+
function safe(ctx, label, run) {
|
|
1340
|
+
try {
|
|
1341
|
+
run()
|
|
1342
|
+
} catch (error) {
|
|
1343
|
+
try {
|
|
1344
|
+
console.error(`[taskflow] ${label} failed:`, error)
|
|
1345
|
+
} catch {
|
|
1346
|
+
// console may be unavailable in a stripped host
|
|
1347
|
+
}
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
const name = 'taskflow'
|
|
1352
|
+
const inject = ['slots', 'locale']
|
|
1353
|
+
|
|
1354
|
+
function apply(ctx) {
|
|
1355
|
+
const applied = {}
|
|
1356
|
+
const mark = (label, run) => safe(ctx, label, () => {
|
|
1357
|
+
run()
|
|
1358
|
+
applied[label] = true
|
|
1359
|
+
})
|
|
1360
|
+
try {
|
|
1361
|
+
fallbackLang = String((typeof navigator !== 'undefined' && navigator.language) || 'zh').toLowerCase().startsWith('zh') ? 'zh' : 'en'
|
|
1362
|
+
} catch {
|
|
1363
|
+
fallbackLang = 'zh'
|
|
1364
|
+
}
|
|
1365
|
+
mark('styles', () => injectStyles())
|
|
1366
|
+
mark('handshake', () => announce())
|
|
1367
|
+
|
|
1368
|
+
// Dictionaries + a bound translator. `locale` is declared in `inject`, so
|
|
1369
|
+
// the service is present here; the guard keeps a stripped-down host alive.
|
|
1370
|
+
if (ctx.locale && typeof ctx.locale.register === 'function') {
|
|
1371
|
+
ctx.effect(() => ctx.locale.register(NS, { zh: ZH, en: EN }), 'taskflow: dictionaries')
|
|
1372
|
+
try {
|
|
1373
|
+
bound = ctx.locale.bind(NS)
|
|
1374
|
+
} catch {
|
|
1375
|
+
bound = null
|
|
1376
|
+
}
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
// One surface only: a floating pill above the composer that expands into a
|
|
1380
|
+
// floating card. It reuses the shipped `todo` cell, so the composer keeps a
|
|
1381
|
+
// single todo surface (lower priority shadows the built-in panel).
|
|
1382
|
+
// Mutually exclusive dock modes: ours (shadows the shipped panel) or the
|
|
1383
|
+
// shipped panel alone. Switchable at runtime from Settings → 任务流.
|
|
1384
|
+
let dockHandle = null
|
|
1385
|
+
const applyDockMode = (mode) => {
|
|
1386
|
+
if (dockHandle) {
|
|
1387
|
+
try {
|
|
1388
|
+
dockHandle()
|
|
1389
|
+
} catch {
|
|
1390
|
+
// already disposed
|
|
1391
|
+
}
|
|
1392
|
+
dockHandle = null
|
|
1393
|
+
}
|
|
1394
|
+
applied.dockMode = mode
|
|
1395
|
+
if (mode === 'builtin') return
|
|
1396
|
+
try {
|
|
1397
|
+
dockHandle = ctx.slots.inject('conversation.input.dock', () => ctx.slots.register({
|
|
1398
|
+
name: 'conversation.input.dock',
|
|
1399
|
+
id: 'todo',
|
|
1400
|
+
order: 0,
|
|
1401
|
+
priority: -1,
|
|
1402
|
+
locale: NS,
|
|
1403
|
+
}, (props) => h(SurfaceBoundary, { part: 'dock' }, h(TaskStrip, props))))
|
|
1404
|
+
} catch (error) {
|
|
1405
|
+
reportGeometry('registerFailed', null, `dock: ${String(error?.message ?? error)}`)
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
applyDockModeRef.current = applyDockMode
|
|
1409
|
+
const modeListener = (mode) => applyDockMode(mode)
|
|
1410
|
+
dockModeListeners.add(modeListener)
|
|
1411
|
+
ctx.effect(() => () => dockModeListeners.delete(modeListener), 'taskflow: dock mode listener')
|
|
1412
|
+
applyDockMode(readDockMode())
|
|
1413
|
+
|
|
1414
|
+
mark('settings', () => ctx.effect(() => ctx.slots.inject('settings.section', () => ctx.slots.register({
|
|
1415
|
+
name: 'settings.section',
|
|
1416
|
+
id: 'taskflow',
|
|
1417
|
+
order: 55,
|
|
1418
|
+
label: () => t('settings.title'),
|
|
1419
|
+
locale: NS,
|
|
1420
|
+
}, TaskflowSettings)), 'taskflow: settings page'))
|
|
1421
|
+
ctx.effect(() => () => {
|
|
1422
|
+
if (dockHandle) {
|
|
1423
|
+
try {
|
|
1424
|
+
dockHandle()
|
|
1425
|
+
} catch {
|
|
1426
|
+
// ignore
|
|
1427
|
+
}
|
|
1428
|
+
dockHandle = null
|
|
1429
|
+
}
|
|
1430
|
+
}, 'taskflow: dock switch')
|
|
1431
|
+
|
|
1432
|
+
// Replace the built-in one-line todo row with the full checklist in the
|
|
1433
|
+
// transcript (same key, lower priority shadows the shipped view).
|
|
1434
|
+
mark('todoView', () => ctx.effect(() => ctx.slots.inject('tool.call.toolview', () => {
|
|
1435
|
+
try {
|
|
1436
|
+
return ctx.slots.register({
|
|
1437
|
+
name: 'tool.call.toolview',
|
|
1438
|
+
key: 'todo_write',
|
|
1439
|
+
priority: -1,
|
|
1440
|
+
locale: NS,
|
|
1441
|
+
}, TodoCardEntry)
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
reportGeometry('registerFailed', null, `todoView: ${String(error?.message ?? error)}`)
|
|
1444
|
+
throw error
|
|
1445
|
+
}
|
|
1446
|
+
}), 'taskflow: todo card'))
|
|
1447
|
+
|
|
1448
|
+
startPoller()
|
|
1449
|
+
try {
|
|
1450
|
+
const own = document.querySelector('style[data-plugin-css="dsh-plugin-taskflow"]')
|
|
1451
|
+
applied.builtinTodoPanel = Boolean(document.querySelector('[data-testid="todo-panel"]'))
|
|
1452
|
+
applied.css = own ? (own.dataset.build ?? 'no-build') : 'missing'
|
|
1453
|
+
let rules = []
|
|
1454
|
+
try {
|
|
1455
|
+
rules = own && own.sheet ? Array.from(own.sheet.cssRules) : []
|
|
1456
|
+
} catch {
|
|
1457
|
+
rules = []
|
|
1458
|
+
}
|
|
1459
|
+
applied.cssRules = rules.length
|
|
1460
|
+
applied.hasPauseRule = rules.some((rule) => String(rule.cssText).includes('data-waiting'))
|
|
1461
|
+
applied.hasSpinRule = rules.some((rule) => String(rule.cssText).includes('tf-spin'))
|
|
1462
|
+
} catch {
|
|
1463
|
+
applied.css = 'unknown'
|
|
1464
|
+
}
|
|
1465
|
+
reportGeometry('apply', null, JSON.stringify(applied))
|
|
1466
|
+
|
|
1467
|
+
ctx.effect(() => () => {
|
|
1468
|
+
if (poller !== null) {
|
|
1469
|
+
window.clearInterval(poller)
|
|
1470
|
+
poller = null
|
|
1471
|
+
}
|
|
1472
|
+
openSidebar = null
|
|
1473
|
+
}, 'taskflow: lifecycle')
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
exports.apply = apply
|
|
1477
|
+
exports.inject = inject
|
|
1478
|
+
exports.name = name
|
|
1479
|
+
return module.exports
|
|
1480
|
+
},
|
|
1481
|
+
})
|