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.
@@ -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
+ })