dsh-plan-completion 0.1.0 → 0.1.2

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/client.js CHANGED
@@ -1,1995 +1,2041 @@
1
- /**
2
- * dsh-plan-completion — Client (browser) half.
3
- *
4
- * Single browser artifact loaded through window.__ModuleLoader__:
5
- * - `conversation.input.dock` : full-width suggestion row + persistent plan entry, above the composer card
6
- * - `shell.overlay` : anchored plan popover
7
- * - window keydown capture : Tab / Esc / Alt+1..3 / Ctrl+Alt+P
8
- *
9
- * Constraints kept on purpose (see task-3): no JSX, only `require('react')`,
10
- * theme colours come from `--dsw-alias-*` tokens only, no host DOM writes,
11
- * no document.body manipulation.
12
- *
13
- * All places that depend on P0 contracts are isolated in one function each:
14
- * getPlanState() / fetchPlanState() -> plan state source (HTTP half)
15
- * insertIntoComposer() -> Tab fill (captureInsertion + insertText)
16
- * replaceComposerDraft() -> whole-draft writes (setDraft)
17
- * isEditorFocused() / hasOpenComposerMenu() -> keyboard gate heuristics
18
- */
19
-
20
- window.__ModuleLoader__.load({
21
- id: 'dsh-plan-completion',
22
- factory(require) {
23
- const React = require('react');
24
- const h = React.createElement;
25
-
26
- // =====================================================================
27
- // Constants
28
- // =====================================================================
29
- const NS = 'plan-completion';
30
- // Full-width entries above the composer card (purpose: "Full-width entries above the composer card").
31
- const DOCK_SLOT = 'conversation.input.dock';
32
- const OVERLAY_SLOT = 'shell.overlay';
33
- const DOCK_ID = 'plan-completion-dock';
34
- const OVERLAY_ID = 'plan-completion-popover';
35
- const DOCK_ORDER = 30;
36
- const OVERLAY_ORDER = 50;
37
- const STATE_PATH = '/plan-completion/state';
38
- // Turn-end refresh attempts (ms). The Host may publish a fast heuristic
39
- // suggestion first and refine it with the model a few seconds later, so the
40
- // whole chain runs to 9s; planSignature de-dupes unchanged payloads.
41
- const TURN_RETRY_DELAYS = [300, 800, 1600, 3000, 6000, 9000];
42
- // Fallback cadence while the host does not expose a usable `running` value.
43
- const POLL_MS = 4000;
44
- // Watchdog cadence for a known-but-static `running`: no state request for this
45
- // long means suggestions may be stuck, so issue one `reason=stale-poll`.
46
- const STALE_POLL_MS = 15000;
47
- const STEP_STATUSES = ['todo', 'doing', 'done', 'dropped'];
48
- // Client build marker surfaced to the Host diagnostic route (proves which bundle is live).
49
- const CLIENT_BUILD = 'capsule-2026-11-01';
50
- // TEMP verification flag: auto-open the plan panel shortly after mount so a static
51
- // screenshot can be taken without synthetic clicks. Set back to false before release.
52
- const DEV_OPEN_POPOVER = false;
53
- // TEMP verification flag: render the hero fallback entry even when a dock exists.
54
- const DEV_HERO_PREVIEW = false;
55
-
56
- /**
57
- * Optional static plan state for local/preview development:
58
- * set `window.__DEV_PLAN__ = { ...plan }` in the page console and it wins
59
- * over the HTTP adapter. Kept out of any production path.
60
- */
61
- const DEV_PLAN = (typeof globalThis !== 'undefined' && globalThis.__DEV_PLAN__) || null;
62
-
63
- // =====================================================================
64
- // i18n (zh primary, en secondary). Registered through ctx.locale.
65
- // =====================================================================
66
- const MESSAGES = {
67
- zh: {
68
- 'entry.aria': '打开会话计划面板',
69
- 'entry.title': '会话计划',
70
- 'entry.label': '计划',
71
- 'suggest.noplan': '发送一条消息后自动生成计划与建议',
72
- 'suggest.hint': 'Tab 填入',
73
- 'suggest.alts': 'Alt+1..{count}',
74
- 'suggest.dismiss': '关闭本次建议',
75
- 'popover.title': '会话计划',
76
- 'popover.intent': '目标',
77
- 'popover.steps': '步骤',
78
- 'popover.reason': '最近更新',
79
- 'popover.suggestion': '下一步建议',
80
- 'popover.fill': '填入草稿',
81
- 'popover.sync': '同步给 Agent',
82
- 'popover.close': '关闭',
83
- 'popover.empty': '暂无计划',
84
- 'popover.updated': '更新于 {time}',
85
- 'popover.stale': '计划可能已过期,将在下一回合重建',
86
- 'status.active': '进行中',
87
- 'status.completed': '已完成',
88
- 'status.archived': '已归档',
89
- 'step.todo': '待办',
90
- 'step.doing': '进行中',
91
- 'step.done': '已完成',
92
- 'step.dropped': '已放弃',
93
- 'sync.prefix': '请按当前会话计划继续推进。',
94
- 'sync.intent': '目标:{value}。',
95
- 'sync.done': '已完成:{value}。',
96
- 'sync.doing': '进行中:{value}。',
97
- 'sync.todo': '待办:{value}。',
98
- 'sync.reason': '最近更新:{value}。',
99
- 'sync.next': '下一步建议:{value}。',
100
- 'sync.fallback': '请先确认当前进度,再继续推进。'
101
- },
102
- en: {
103
- 'entry.aria': 'Open the session plan panel',
104
- 'entry.title': 'Session plan',
105
- 'entry.label': 'Plan',
106
- 'suggest.noplan': 'Send a message to generate a plan and suggestion',
107
- 'suggest.hint': 'Tab to fill',
108
- 'suggest.alts': 'Alt+1..{count}',
109
- 'suggest.dismiss': 'Dismiss this suggestion',
110
- 'popover.title': 'Session plan',
111
- 'popover.intent': 'Intent',
112
- 'popover.steps': 'Steps',
113
- 'popover.reason': 'Latest update',
114
- 'popover.suggestion': 'Next suggestion',
115
- 'popover.fill': 'Fill draft',
116
- 'popover.sync': 'Sync to Agent',
117
- 'popover.close': 'Close',
118
- 'popover.empty': 'No plan yet',
119
- 'popover.updated': 'Updated {time}',
120
- 'popover.stale': 'The plan may be stale and will be rebuilt next turn',
121
- 'status.active': 'Active',
122
- 'status.completed': 'Completed',
123
- 'status.archived': 'Archived',
124
- 'step.todo': 'Todo',
125
- 'step.doing': 'In progress',
126
- 'step.done': 'Done',
127
- 'step.dropped': 'Dropped',
128
- 'sync.prefix': 'Please continue based on the current session plan.',
129
- 'sync.intent': 'Intent: {value}.',
130
- 'sync.done': 'Done: {value}.',
131
- 'sync.doing': 'In progress: {value}.',
132
- 'sync.todo': 'Pending: {value}.',
133
- 'sync.reason': 'Latest update: {value}.',
134
- 'sync.next': 'Next suggestion: {value}.',
135
- 'sync.fallback': 'Please confirm the current progress before continuing.'
136
- }
137
- };
138
-
139
- let hostT = null;
140
- // Test-only hook: the Host's /plan-completion/theme route puts `debugTheme` into the
141
- // state payload so an automated dark/light check can switch the app theme.
142
- let pluginThemeService = null;
143
- let appliedDebugTheme = null;
144
-
145
- function reportTheme(phase, detail) {
146
- try {
147
- fetch('/plan-completion/diag', {
148
- method: 'POST',
149
- credentials: 'same-origin',
150
- headers: { 'content-type': 'application/json' },
151
- body: JSON.stringify(Object.assign({ phase: phase, build: CLIENT_BUILD }, detail || {})),
152
- }).catch(function () {});
153
- } catch (error) {
154
- /* ignore */
155
- }
156
- }
157
-
158
- function applyDebugTheme(mode) {
159
- if (!mode || appliedDebugTheme === mode) return;
160
- if (!pluginThemeService) {
161
- reportTheme('theme-error', { error: 'no-theme-service', mode: mode });
162
- return;
163
- }
164
- try {
165
- pluginThemeService.setTheme(mode);
166
- appliedDebugTheme = mode;
167
- reportTheme('theme-applied', { mode: mode });
168
- } catch (error) {
169
- reportTheme('theme-error', { error: String(error), mode: mode });
170
- }
171
- }
172
-
173
- function detectLang() {
174
- try {
175
- if (typeof navigator === 'undefined' || !navigator) return 'zh';
176
- const value = navigator.language || (navigator.languages && navigator.languages[0]);
177
- if (typeof value === 'string' && /^en/i.test(value)) return 'en';
178
- } catch (error) {
179
- /* ignore */
180
- }
181
- return 'zh';
182
- }
183
-
184
- const FALLBACK_LANG = detectLang();
185
-
186
- function fallbackT(key, vars) {
187
- const dict = MESSAGES[FALLBACK_LANG] || MESSAGES.zh;
188
- let text = dict[key];
189
- if (typeof text !== 'string') text = MESSAGES.zh[key];
190
- if (typeof text !== 'string') text = key;
191
- if (vars && typeof vars === 'object') {
192
- text = text.replace(/\{(\w+)\}/g, function replace(match, name) {
193
- return vars[name] === undefined ? match : String(vars[name]);
194
- });
195
- }
196
- return text;
197
- }
198
-
199
- function makeT(props) {
200
- const propT = props && typeof props.t === 'function' ? props.t : null;
201
- return function translate(key, vars) {
202
- const bound = propT || hostT;
203
- if (bound) {
204
- try {
205
- const value = bound(key, vars);
206
- if (typeof value === 'string' && value !== key) return value;
207
- } catch (error) {
208
- /* fall through to the local dictionary */
209
- }
210
- }
211
- return fallbackT(key, vars);
212
- };
213
- }
214
-
215
- // =====================================================================
216
- // Styles — theme tokens only (host light/dark switching is automatic).
217
- // =====================================================================
218
- const DOCK_CSS = [
219
- // Fixed-height full-width row directly above the composer card: a floating
220
- // suggestion pill on the left and the persistent plan pill on the right.
221
- // The row keeps its height without a suggestion so the composer never jumps.
222
- '.dpc-root{display:flex;align-items:center;gap:8px;width:100%;min-width:0;height:36px;box-sizing:border-box;padding:2px 12px 8px 12px;pointer-events:auto;font-size:12px;line-height:18px}',
223
- '.dpc-suggest-placeholder{flex:0 0 0;width:0;min-width:0;height:0;overflow:hidden}',
224
- '.dpc-pill{display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;min-width:0;height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;max-width:100%;box-shadow:0 2px 10px rgba(0,0,0,.08)}',
225
- '.dpc-suggest-pill{flex:0 1 auto;min-width:0;overflow:hidden}',
226
- '.dpc-suggest-pill:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
227
- '.dpc-suggest-icon{flex:none;color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:12px}',
228
- '.dpc-suggest-hint{flex:none;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
229
- '.dpc-suggest-sep{flex:none;color:var(--dsw-alias-label-tertiary)}',
230
- '.dpc-suggest-text{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}',
231
- '.dpc-suggest-alts{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap}',
232
- '.dpc-dismiss{flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;padding:1px 4px;border-radius:999px;cursor:pointer}',
233
- '.dpc-dismiss:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
234
- '.dpc-entry{flex:none;margin-left:0;cursor:pointer;opacity:.85;transition:opacity .12s ease}',
235
- '.dpc-entry:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));opacity:1}',
236
- '.dpc-entry-strong{opacity:1}',
237
- '.dpc-entry[aria-expanded="true"]{opacity:1}',
238
- '.dpc-entry-icon{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:11px}',
239
- '.dpc-entry-version{color:var(--dsw-alias-label-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}',
240
- // Always-on state: when the current session has no plan yet (new / cleared
241
- // conversation), the row still renders with a hint so the bar never vanishes.
242
- '.dpc-hint-pill{flex:0 1 auto;min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);border-style:dashed;box-shadow:none;background:transparent}',
243
- '.dpc-hint-pill .dpc-suggest-text{color:var(--dsw-alias-label-tertiary)}',
244
- '.dpc-entry-label{color:var(--dsw-alias-label-secondary);white-space:nowrap}'
245
- ].join('\n');
246
-
247
- const POPOVER_CSS = [
248
- '.dpc-popover{display:flex;flex-direction:column;gap:8px;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}',
249
- '.dpc-pop-head{display:flex;align-items:center;gap:8px;min-width:0}',
250
- '.dpc-pop-title{flex:none;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary)}',
251
- '.dpc-pop-ver{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
252
- '.dpc-pop-chip{flex:none;padding:0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
253
- '.dpc-pop-chip[data-status="completed"]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}',
254
- '.dpc-pop-chip[data-status="active"]{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
255
- '.dpc-pop-chip[data-status="archived"]{color:var(--dsw-alias-label-tertiary)}',
256
- '.dpc-pop-close{margin-left:auto;flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px;padding:0 4px;border-radius:4px;cursor:pointer}',
257
- '.dpc-pop-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
258
- '.dpc-section{display:flex;flex-direction:column;gap:2px;min-width:0}',
259
- '.dpc-label{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
260
- '.dpc-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}',
261
- '.dpc-stale{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary)}',
262
- '.dpc-steps{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}',
263
- '.dpc-step{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:13px;line-height:20px}',
264
- '.dpc-glyph{flex:none;width:16px;text-align:center;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
265
- '.dpc-step[data-status="done"] .dpc-glyph{color:var(--dsw-alias-state-success-primary)}',
266
- '.dpc-step[data-status="doing"] .dpc-glyph{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
267
- '.dpc-step-main{display:flex;flex-direction:column;min-width:0;flex:1}',
268
- '.dpc-step-title{color:var(--dsw-alias-label-primary);word-break:break-word}',
269
- '.dpc-step[data-status="dropped"] .dpc-step-title{color:var(--dsw-alias-label-tertiary);text-decoration:line-through}',
270
- '.dpc-evidence{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);word-break:break-word}',
271
- '.dpc-suggest-line{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);word-break:break-word}',
272
- '.dpc-updated{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
273
- '.dpc-actions{display:flex;flex-wrap:wrap;gap:8px;padding-top:4px}',
274
- '.dpc-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);border-radius:6px;padding:4px 10px;font-size:12px;line-height:18px;cursor:pointer}',
275
- '.dpc-btn:hover{background:var(--dsw-alias-bg-layer-1)}',
276
- '.dpc-btn:disabled{opacity:.45;cursor:default}',
277
- '.dpc-btn[data-primary="true"]{border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
278
- '.dpc-empty{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
279
- // ── v2 skin: card-style plan panel matching the composer card (radius/shadow/type)
280
- '.dpc-popover{gap:0;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));box-shadow:0 12px 32px rgba(0,0,0,.14), 0 2px 8px rgba(0,0,0,.06);overflow:hidden;max-height:min(70vh, 560px)}',
281
- '.dpc-pop-head{flex:none;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);gap:8px}',
282
- '.dpc-pop-title{font-size:14px;font-weight:600;line-height:22px}',
283
- '.dpc-pop-ver{padding:0 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:11px;line-height:20px;font-variant-numeric:tabular-nums}',
284
- '.dpc-pop-chip{padding:0 8px;border-radius:999px;font-size:11px;line-height:20px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));border:0}',
285
- '.dpc-pop-chip[data-status="completed"]{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary, var(--dsw-alias-bg-layer-2))}',
286
- '.dpc-pop-chip[data-status="active"]{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
287
- '.dpc-pop-close{width:24px;height:24px;line-height:24px;padding:0;border-radius:999px;text-align:center}',
288
- '.dpc-pop-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:14px;padding:12px 14px}',
289
- '.dpc-pop-intent{font-size:13px;line-height:21px;color:var(--dsw-alias-label-primary)}',
290
- '.dpc-label{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);letter-spacing:.02em}',
291
- '.dpc-steps{gap:2px}',
292
- '.dpc-step{padding:6px 8px;border-radius:10px;gap:10px;transition:background .12s ease}',
293
- '.dpc-step:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
294
- '.dpc-glyph{width:18px;font-size:12px}',
295
- '.dpc-step-title{font-size:13px;line-height:20px}',
296
- '.dpc-evidence{margin-top:1px}',
297
- '.dpc-reason{font-size:12px;line-height:19px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));white-space:pre-wrap;word-break:break-word}',
298
- '.dpc-suggest-card{padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
299
- '.dpc-suggest-card .dpc-suggest-line{font-size:13px;line-height:20px}',
300
- '.dpc-pop-foot{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1)}',
301
- '.dpc-pop-foot .dpc-updated{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
302
- '.dpc-actions{padding-top:0;gap:8px;flex:none}',
303
- '.dpc-btn{border-radius:999px;padding:5px 12px;font-size:12px;line-height:18px}',
304
- '.dpc-empty{padding:14px;font-size:13px}'
305
- ].join('\n');
306
-
307
- // Hero fallback entry (rendered from shell.overlay when no session-bound dock exists).
308
- const HERO_CSS = [
309
- '.dpc-hero-entry{position:fixed;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;box-shadow:0 2px 10px rgba(0,0,0,.08);cursor:pointer;z-index:40}',
310
- '.dpc-hero-entry:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
311
- '.dpc-hero-entry .dpc-entry-icon{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:11px}'
312
- ].join('\n');
313
-
314
- // =====================================================================
315
- // Small generic helpers
316
- // =====================================================================
317
- function preventDefault(event) {
318
- if (event && typeof event.preventDefault === 'function') event.preventDefault();
319
- }
320
-
321
- function stopPropagation(event) {
322
- if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
323
- }
324
-
325
- function listJoin(items) {
326
- return items.join(FALLBACK_LANG === 'en' ? ', ' : '、');
327
- }
328
-
329
- function truncate(text, limit) {
330
- if (typeof text !== 'string') return '';
331
- if (text.length <= limit) return text;
332
- return text.slice(0, Math.max(0, limit - 1)) + '…';
333
- }
334
-
335
- function formatTime(timestamp) {
336
- try {
337
- const date = new Date(timestamp);
338
- if (isNaN(date.getTime())) return '';
339
- return date.toLocaleTimeString(FALLBACK_LANG === 'en' ? 'en-US' : 'zh-CN', {
340
- hour: '2-digit',
341
- minute: '2-digit'
342
- });
343
- } catch (error) {
344
- return '';
345
- }
346
- }
347
-
348
- // =====================================================================
349
- // Plan state normalization adapters (P0 / cross-half boundary)
350
- // =====================================================================
351
- function normalizeEvidence(raw) {
352
- const out = [];
353
- const list = Array.isArray(raw) ? raw : [];
354
- for (let index = 0; index < list.length && out.length < 6; index += 1) {
355
- const value = list[index];
356
- if (typeof value === 'string' && value.trim() !== '') out.push(truncate(value.trim(), 160));
357
- }
358
- return out;
359
- }
360
-
361
- function normalizeSteps(raw) {
362
- const out = [];
363
- const list = Array.isArray(raw) ? raw : [];
364
- for (let index = 0; index < list.length; index += 1) {
365
- const step = list[index];
366
- if (!step || typeof step !== 'object') continue;
367
- const title = typeof step.title === 'string' ? step.title.trim() : '';
368
- if (title === '') continue;
369
- out.push({
370
- id: typeof step.id === 'string' && step.id !== '' ? step.id : 's' + (index + 1),
371
- title: truncate(title, 300),
372
- status: STEP_STATUSES.indexOf(step.status) >= 0 ? step.status : 'todo',
373
- evidence: normalizeEvidence(step.evidence)
374
- });
375
- }
376
- return out;
377
- }
378
-
379
- function normalizeSuggestion(raw) {
380
- if (raw === null || raw === undefined) return null;
381
- if (typeof raw === 'string') {
382
- const text = raw.trim();
383
- return text === '' ? null : { text: truncate(text, 300), alternates: [] };
384
- }
385
- if (typeof raw !== 'object') return null;
386
- const text = typeof raw.text === 'string' ? raw.text.trim() : '';
387
- if (text === '') return null;
388
- const alternates = [];
389
- const pushAlternate = function pushAlternate(value) {
390
- if (typeof value !== 'string') return;
391
- const alt = value.trim();
392
- if (alt === '' || alt === text || alternates.length >= 2) return;
393
- if (alternates.indexOf(alt) >= 0) return;
394
- alternates.push(truncate(alt, 300));
395
- };
396
- if (Array.isArray(raw.alternates)) {
397
- for (let index = 0; index < raw.alternates.length; index += 1) pushAlternate(raw.alternates[index]);
398
- } else {
399
- pushAlternate(raw.text2);
400
- pushAlternate(raw.text3);
401
- }
402
- return { text: truncate(text, 300), alternates: alternates };
403
- }
404
-
405
- function normalizeVersion(raw) {
406
- if (!raw || typeof raw !== 'object') return null;
407
- const gen = Number(raw.gen);
408
- const rev = Number(raw.rev);
409
- if (!isFinite(gen) || !isFinite(rev)) return null;
410
- return { gen: gen, rev: rev };
411
- }
412
-
413
- /** Tolerate both the Host wire shape and a `{ plan: ... }` envelope. */
414
- function normalizePlan(raw) {
415
- if (!raw || typeof raw !== 'object') return null;
416
- const nested = raw.plan && typeof raw.plan === 'object' ? raw.plan : null;
417
- const doc = normalizeVersion(raw.version) ? raw : nested;
418
- if (!doc) return null;
419
- const version = normalizeVersion(doc.version);
420
- if (!version) return null;
421
- // Fields may arrive on the flat object or inside the nested `plan` object.
422
- const pick = function pick(field) {
423
- if (doc[field] !== undefined && doc[field] !== null) return doc[field];
424
- return raw[field];
425
- };
426
- const status = pick('status');
427
- const updatedAt = pick('updatedAt');
428
- const intent = pick('intent');
429
- const reason = pick('reason');
430
- return {
431
- sessionId: typeof pick('sessionId') === 'string' ? pick('sessionId') : null,
432
- version: version,
433
- status: status === 'completed' || status === 'archived' ? status : 'active',
434
- intent: typeof intent === 'string' ? truncate(intent, 400) : '',
435
- steps: normalizeSteps(pick('steps')),
436
- updatedAt: typeof updatedAt === 'number' && isFinite(updatedAt) ? updatedAt : null,
437
- reason: typeof reason === 'string' ? truncate(reason, 300) : '',
438
- suggestion: normalizeSuggestion(pick('suggestion')),
439
- stale: pick('stale') === true
440
- };
441
- }
442
-
443
- function planSignature(plan) {
444
- if (!plan) return null;
445
- try {
446
- return JSON.stringify([
447
- plan.version.gen,
448
- plan.version.rev,
449
- plan.status,
450
- plan.intent,
451
- plan.updatedAt,
452
- plan.reason,
453
- plan.stale,
454
- plan.steps.map(function stepKey(step) {
455
- return [step.id, step.title, step.status, step.evidence];
456
- }),
457
- plan.suggestion ? [plan.suggestion.text, plan.suggestion.alternates] : null
458
- ]);
459
- } catch (error) {
460
- return null;
461
- }
462
- }
463
-
464
- function getSuggestion(plan) {
465
- return plan && plan.suggestion ? plan.suggestion : null;
466
- }
467
-
468
- function suggestionKey(plan, suggestion) {
469
- const version = plan && plan.version ? plan.version.gen + '.' + plan.version.rev : '';
470
- return version + ':' + (suggestion ? suggestion.text : '');
471
- }
472
-
473
- function versionLabel(plan) {
474
- return plan && plan.version ? 'v' + plan.version.gen + '.' + plan.version.rev : '';
475
- }
476
-
477
- function planFillText(plan) {
478
- if (!plan) return '';
479
- const suggestion = getSuggestion(plan);
480
- if (suggestion) return suggestion.text;
481
- const doing = plan.steps.filter(function isDoing(step) {
482
- return step.status === 'doing';
483
- });
484
- const todo = plan.steps.filter(function isTodo(step) {
485
- return step.status === 'todo';
486
- });
487
- const next = doing[0] || todo[0];
488
- return next ? next.title : '';
489
- }
490
-
491
- /** "Sync to Agent": render the plan as a natural-language instruction (fills draft only). */
492
- function buildSyncPrompt(plan) {
493
- if (!plan) return '';
494
- const t = makeT(null);
495
- const groups = { done: [], doing: [], todo: [] };
496
- for (let index = 0; index < plan.steps.length; index += 1) {
497
- const step = plan.steps[index];
498
- if (step.status === 'done') groups.done.push(step.title);
499
- else if (step.status === 'doing') groups.doing.push(step.title);
500
- else if (step.status === 'todo') groups.todo.push(step.title);
501
- }
502
- const parts = [t('sync.prefix')];
503
- if (plan.intent) parts.push(t('sync.intent', { value: plan.intent }));
504
- if (groups.done.length > 0) parts.push(t('sync.done', { value: listJoin(groups.done) }));
505
- if (groups.doing.length > 0) parts.push(t('sync.doing', { value: listJoin(groups.doing) }));
506
- if (groups.todo.length > 0) parts.push(t('sync.todo', { value: listJoin(groups.todo) }));
507
- if (plan.reason) parts.push(t('sync.reason', { value: plan.reason }));
508
- const suggestion = getSuggestion(plan);
509
- if (suggestion) parts.push(t('sync.next', { value: suggestion.text }));
510
- if (parts.length === 1) parts.push(t('sync.fallback'));
511
- return parts.join(' ').replace(/\s+/g, ' ').trim();
512
- }
513
-
514
- // =====================================================================
515
- // Module store shared by dock, overlay and the global keyboard handler
516
- // =====================================================================
517
- function createStore(initial) {
518
- let state = initial;
519
- const listeners = new Set();
520
- return {
521
- get: function get() {
522
- return state;
523
- },
524
- set: function set(patch) {
525
- let changed = false;
526
- for (const key in patch) {
527
- if (state[key] !== patch[key]) {
528
- changed = true;
529
- break;
530
- }
531
- }
532
- if (!changed) return;
533
- state = Object.assign({}, state, patch);
534
- const snapshot = [];
535
- listeners.forEach(function collect(listener) {
536
- snapshot.push(listener);
537
- });
538
- for (let index = 0; index < snapshot.length; index += 1) {
539
- try {
540
- snapshot[index]();
541
- } catch (error) {
542
- /* a broken subscriber must not break the keyboard path */
543
- }
544
- }
545
- },
546
- subscribe: function subscribe(listener) {
547
- listeners.add(listener);
548
- return function unsubscribe() {
549
- listeners.delete(listener);
550
- };
551
- }
552
- };
553
- }
554
-
555
- const store = createStore({
556
- plan: null,
557
- planSig: null,
558
- sessionId: undefined,
559
- overlayOpen: false,
560
- anchorRect: null,
561
- dismissedKey: null,
562
- suggestionVisible: false,
563
- inputActions: null,
564
- draftText: '',
565
- inputEmpty: true,
566
- dockRoot: null,
567
- triggerEl: null,
568
- popoverRoot: null
569
- });
570
-
571
- function useStoreSelector(selector) {
572
- const [value, setValue] = React.useState(function initial() {
573
- return selector(store.get());
574
- });
575
- React.useEffect(function subscribeToStore() {
576
- const sync = function sync() {
577
- setValue(function compare(previous) {
578
- const next = selector(store.get());
579
- return Object.is(previous, next) ? previous : next;
580
- });
581
- };
582
- sync();
583
- return store.subscribe(sync);
584
- }, [selector]);
585
- return value;
586
- }
587
-
588
- const selectPlan = function selectPlan(state) {
589
- return state.plan;
590
- };
591
- const selectDismissedKey = function selectDismissedKey(state) {
592
- return state.dismissedKey;
593
- };
594
- const selectOverlayOpen = function selectOverlayOpen(state) {
595
- return state.overlayOpen;
596
- };
597
- const selectAll = function selectAll(state) {
598
- return state;
599
- };
600
-
601
- // =====================================================================
602
- // Plan state source (P0 boundary #1) — same-origin HTTP route.
603
- // =====================================================================
604
- let planRefresher = null;
605
- let lastPlanSessionId;
606
- // Timestamp of the last state request, shared by every trigger path; drives the
607
- // `stale-poll` watchdog. Updated whenever a request is actually issued.
608
- let lastStateRequestAt = 0;
609
-
610
- /**
611
- * Resolve `{ ok, plan }`; `ok` false means the route is absent or the payload is unusable.
612
- * `reason` is diagnostic only (`mount` / `session` / `turn-end` / `poll` /
613
- * `stale-poll` / `popover`) and `running` mirrors the client-side running
614
- * detection (`true` / `false` / `undefined`). Both are forwarded as query
615
- * parameters so the Host log can attribute every request and reveal what
616
- * `useSession` returned.
617
- */
618
- function fetchPlanState(sessionId, reason, running) {
619
- if (DEV_PLAN) {
620
- return Promise.resolve({ ok: true, plan: normalizePlan(DEV_PLAN) });
621
- }
622
- if (typeof sessionId !== 'string' || sessionId === '') {
623
- return Promise.resolve({ ok: false, plan: null });
624
- }
625
- let request = null;
626
- const requestReason = typeof reason === 'string' && reason !== '' ? reason : 'mount';
627
- const runningText = running === true || running === false ? String(running) : 'undefined';
628
- try {
629
- if (typeof fetch !== 'function') return Promise.resolve({ ok: false, plan: null });
630
- const url =
631
- STATE_PATH +
632
- '?sessionId=' +
633
- encodeURIComponent(sessionId) +
634
- '&reason=' +
635
- encodeURIComponent(requestReason) +
636
- '&running=' +
637
- encodeURIComponent(runningText);
638
- lastStateRequestAt = Date.now();
639
- request = fetch(url, {
640
- method: 'GET',
641
- credentials: 'same-origin',
642
- headers: { accept: 'application/json' }
643
- });
644
- } catch (error) {
645
- return Promise.resolve({ ok: false, plan: null });
646
- }
647
- return Promise.resolve(request)
648
- .then(function readResponse(response) {
649
- if (!response || response.ok !== true) return { ok: false, plan: null };
650
- return Promise.resolve(response.json()).then(
651
- function parsed(json) {
652
- try {
653
- if (json && typeof json === 'object' && json.debugTheme) applyDebugTheme(json.debugTheme);
654
- } catch (error) {
655
- /* ignore */
656
- }
657
- return { ok: true, plan: normalizePlan(json) };
658
- },
659
- function badJson() {
660
- return { ok: false, plan: null };
661
- }
662
- );
663
- })
664
- .catch(function networkError() {
665
- return { ok: false, plan: null };
666
- });
667
- }
668
-
669
- /**
670
- * P0-1 warm backfill: ask the Host to build a plan for a session it has never observed
671
- * (old sessions / plugin just enabled). Resolves with the Host's JSON or null.
672
- */
673
- function warmPlanState(sessionId) {
674
- if (typeof sessionId !== 'string' || sessionId === '') return Promise.resolve(null);
675
- let request = null;
676
- try {
677
- request = fetch('/plan-completion/warm', {
678
- method: 'POST',
679
- credentials: 'same-origin',
680
- headers: { 'content-type': 'application/json' },
681
- body: JSON.stringify({ sessionId: sessionId })
682
- });
683
- } catch (error) {
684
- return Promise.resolve(null);
685
- }
686
- return Promise.resolve(request)
687
- .then(function readResponse(response) {
688
- if (!response || response.ok !== true) return null;
689
- return Promise.resolve(response.json()).then(
690
- function parsed(json) {
691
- return json && typeof json === 'object' ? { ok: json.ok === true, json: json } : null;
692
- },
693
- function badJson() {
694
- return null;
695
- }
696
- );
697
- })
698
- .catch(function networkError() {
699
- return null;
700
- });
701
- }
702
-
703
- /**
704
- * P0 boundary: read the current plan state. Keep this signature stable —
705
- * swapping the transport (HTTP route / projection / wire view) only changes
706
- * this function and fetchPlanState.
707
- */
708
- function getPlanState(sessionId, reason, running) {
709
- return fetchPlanState(sessionId, reason, running).then(function selectPlan(result) {
710
- return result.plan;
711
- });
712
- }
713
-
714
- function applyPlanResult(sessionId, plan) {
715
- if (!plan) return;
716
- if (plan.sessionId && sessionId && plan.sessionId !== sessionId) return;
717
- const current = store.get();
718
- if (current.sessionId !== undefined && current.sessionId !== sessionId) return;
719
- const signature = planSignature(plan);
720
- if (current.plan && current.planSig === signature) return;
721
- store.set({ plan: plan, planSig: signature });
722
- }
723
-
724
- /**
725
- * Server said "no plan for this session" (unknown / cleared / reset). Drop the cached plan
726
- * so the bar falls back to its always-on state instead of showing a stale suggestion.
727
- */
728
- function clearPlanResult(sessionId) {
729
- const current = store.get();
730
- if (current.sessionId !== undefined && sessionId && current.sessionId !== sessionId) return;
731
- if (!current.plan) return;
732
- store.set({ plan: null, planSig: null, dismissedKey: null, overlayOpen: false });
733
- }
734
-
735
- function handlePlanFetch(sessionId, result) {
736
- if (!result || result.ok !== true) return;
737
- if (result.plan) applyPlanResult(sessionId, result.plan);
738
- else clearPlanResult(sessionId);
739
- }
740
-
741
- // =====================================================================
742
- // Composer write adapters (P0 boundary #2)
743
- // =====================================================================
744
- /** Tab fill: insert at the caret. */
745
- function insertIntoComposer(inputActions, text) {
746
- if (!inputActions || typeof text !== 'string' || text === '') return false;
747
- try {
748
- if (typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
749
- const span = inputActions.captureInsertion();
750
- const result = inputActions.insertText(text, span);
751
- return result !== false;
752
- }
753
- if (typeof inputActions.insertText === 'function') {
754
- const result = inputActions.insertText(text);
755
- return result !== false;
756
- }
757
- if (typeof inputActions.setDraft === 'function') {
758
- inputActions.setDraft(text);
759
- return true;
760
- }
761
- } catch (error) {
762
- return false;
763
- }
764
- return false;
765
- }
766
-
767
- /** Whole-draft replace: used by popover [Fill draft] / [Sync to Agent]. */
768
- function replaceComposerDraft(inputActions, text) {
769
- if (!inputActions || typeof text !== 'string' || text === '') return false;
770
- try {
771
- if (typeof inputActions.setDraft === 'function') {
772
- inputActions.setDraft(text);
773
- return true;
774
- }
775
- return insertIntoComposer(inputActions, text);
776
- } catch (error) {
777
- return false;
778
- }
779
- }
780
-
781
- // =====================================================================
782
- // Focus / menus (P0 boundary #3): conservative keyboard gating
783
- // =====================================================================
784
- function isEditableElement(element) {
785
- if (!element || typeof element !== 'object') return false;
786
- const tag = typeof element.tagName === 'string' ? element.tagName.toLowerCase() : '';
787
- if (tag === 'textarea' || tag === 'input') return true;
788
- if (element.isContentEditable === true) return true;
789
- try {
790
- const attr = typeof element.getAttribute === 'function' ? element.getAttribute('contenteditable') : null;
791
- if (attr === '' || attr === 'true' || attr === 'plaintext-only') return true;
792
- } catch (error) {
793
- /* ignore */
794
- }
795
- return false;
796
- }
797
-
798
- /** Nearest ancestor of our dock that already owns the composer card. */
799
- function composerRootFromDock(dockEl) {
800
- if (!dockEl || !dockEl.parentElement) return null;
801
- let node = dockEl.parentElement;
802
- for (let depth = 0; depth < 6 && node && node !== document.body; depth += 1) {
803
- try {
804
- if (typeof node.querySelector === 'function' && node.querySelector('[data-composer-card]') !== null) return node;
805
- } catch (error) {
806
- return null;
807
- }
808
- node = node.parentElement;
809
- }
810
- return null;
811
- }
812
-
813
- /**
814
- * True only when the focused element is the composer editor:
815
- * editable AND (inside a composer card OR inside an ancestor of our dock OR
816
- * carries a composer-ish class). Never true for the suggestion bar itself.
817
- */
818
- function isEditorFocused(dockEl) {
819
- try {
820
- const active = document.activeElement;
821
- if (!active || active === document.body) return false;
822
- if (!isEditableElement(active)) return false;
823
- if (dockEl && typeof dockEl.contains === 'function' && dockEl.contains(active)) return false;
824
- // Evidence 1: host marks the composer card.
825
- if (typeof active.closest === 'function' && active.closest('[data-composer-card]') !== null) return true;
826
- // Evidence 2: focused editor shares an ancestor with our dock.
827
- if (dockEl && typeof dockEl.parentElement === 'object' && dockEl.parentElement) {
828
- let node = dockEl.parentElement;
829
- for (let depth = 0; depth < 5 && node && node !== document.body; depth += 1) {
830
- if (typeof node.contains === 'function' && node.contains(active)) return true;
831
- node = node.parentElement;
832
- }
833
- }
834
- // Evidence 3: class-name fallback.
835
- let node = active;
836
- for (let depth = 0; depth < 8 && node && node !== document.body; depth += 1) {
837
- const className = typeof node.className === 'string' ? node.className : '';
838
- if (/composer/i.test(className)) return true;
839
- node = node.parentElement;
840
- }
841
- return false;
842
- } catch (error) {
843
- return false;
844
- }
845
- }
846
-
847
- /**
848
- * Conservative "a slash/@/command menu is up" check. Empty drafts cannot
849
- * open slash/@ menus, so this guards the programmatic "+" command menu.
850
- */
851
- function hasOpenComposerMenu(dockEl) {
852
- try {
853
- const root = composerRootFromDock(dockEl);
854
- if (!root || typeof root.querySelectorAll !== 'function') return false;
855
- const nodes = root.querySelectorAll(
856
- '[data-composer-overlay] [role="listbox"], [data-composer-overlay] [role="menu"], [data-composer-overlay] [role="dialog"]'
857
- );
858
- for (let index = 0; index < nodes.length; index += 1) {
859
- const node = nodes[index];
860
- if (node.getClientRects && node.getClientRects().length > 0) return true;
861
- }
862
- return false;
863
- } catch (error) {
864
- return false;
865
- }
866
- }
867
-
868
- function hasTriggerText(text) {
869
- const value = typeof text === 'string' ? text.replace(/^\s+/, '') : '';
870
- return value.startsWith('/') || value.startsWith('@');
871
- }
872
-
873
- function isAnyModalOpen() {
874
- try {
875
- if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return false;
876
- const nodes = document.querySelectorAll('[aria-modal="true"], dialog[open]');
877
- for (let index = 0; index < nodes.length; index += 1) {
878
- const node = nodes[index];
879
- if (node.getClientRects && node.getClientRects().length > 0) return true;
880
- }
881
- return false;
882
- } catch (error) {
883
- return false;
884
- }
885
- }
886
-
887
- // =====================================================================
888
- // Overlay / suggestion control
889
- // =====================================================================
890
- function openOverlay(triggerEl) {
891
- const current = store.get();
892
- const anchorEl = triggerEl || current.triggerEl || current.dockRoot;
893
- let rect = null;
894
- try {
895
- if (anchorEl && typeof anchorEl.getBoundingClientRect === 'function') {
896
- const box = anchorEl.getBoundingClientRect();
897
- rect = { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height };
898
- }
899
- } catch (error) {
900
- rect = null;
901
- }
902
- store.set({ overlayOpen: true, triggerEl: anchorEl || null, anchorRect: rect });
903
- }
904
-
905
- function closeOverlay() {
906
- store.set({ overlayOpen: false });
907
- }
908
-
909
- function toggleOverlay(triggerEl) {
910
- if (store.get().overlayOpen) {
911
- closeOverlay();
912
- return;
913
- }
914
- openOverlay(triggerEl);
915
- }
916
-
917
- function dismissSuggestion() {
918
- const current = store.get();
919
- const suggestion = getSuggestion(current.plan);
920
- if (!suggestion) return;
921
- store.set({ dismissedKey: suggestionKey(current.plan, suggestion) });
922
- }
923
-
924
- function suggestionTexts(plan) {
925
- const suggestion = getSuggestion(plan);
926
- if (!suggestion) return [];
927
- return [suggestion.text].concat(suggestion.alternates || []);
928
- }
929
-
930
- // =====================================================================
931
- // Global keyboard handler (window capture, installed through ctx.effect)
932
- // =====================================================================
933
- function canTabFill(state) {
934
- if (!state.suggestionVisible) return false;
935
- const suggestion = getSuggestion(state.plan);
936
- if (!suggestion) return false;
937
- if (state.dismissedKey === suggestionKey(state.plan, suggestion)) return false;
938
- if (!state.inputEmpty) return false;
939
- if (typeof state.draftText === 'string' && state.draftText.trim() !== '') return false;
940
- if (hasTriggerText(state.draftText)) return false;
941
- if (!isEditorFocused(state.dockRoot)) return false;
942
- if (hasOpenComposerMenu(state.dockRoot)) return false;
943
- return true;
944
- }
945
-
946
- function handleKeyDown(event) {
947
- const e = event;
948
- if (!e || e.defaultPrevented) return;
949
- if (e.isComposing || e.keyCode === 229) return;
950
- const key = typeof e.key === 'string' ? e.key : '';
951
- const state = store.get();
952
-
953
- // Ctrl+Alt+P — toggle the plan popover (fixed global shortcut).
954
- if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (key === 'p' || key === 'P')) {
955
- if (isAnyModalOpen()) return;
956
- preventDefault(e);
957
- stopPropagation(e);
958
- toggleOverlay(null);
959
- return;
960
- }
961
-
962
- // Tab — fill the current suggestion.
963
- if (key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing) {
964
- if (!canTabFill(state)) return;
965
- const suggestion = getSuggestion(state.plan);
966
- if (!suggestion) return;
967
- preventDefault(e);
968
- stopPropagation(e);
969
- insertIntoComposer(state.inputActions, suggestion.text);
970
- return;
971
- }
972
-
973
- // Esc — close the popover, else dismiss the visible suggestion.
974
- if (key === 'Escape' && !e.ctrlKey && !e.altKey && !e.metaKey) {
975
- if (state.overlayOpen) {
976
- if (isAnyModalOpen()) return;
977
- preventDefault(e);
978
- stopPropagation(e);
979
- closeOverlay();
980
- return;
981
- }
982
- if (state.suggestionVisible && isEditorFocused(state.dockRoot)) {
983
- preventDefault(e);
984
- stopPropagation(e);
985
- dismissSuggestion();
986
- return;
987
- }
988
- return;
989
- }
990
-
991
- // Alt+1..3 — pick the primary suggestion or one of its alternates.
992
- if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && /^[1-3]$/.test(key)) {
993
- if (!state.suggestionVisible) return;
994
- if (state.dismissedKey === suggestionKey(state.plan, getSuggestion(state.plan))) return;
995
- const texts = suggestionTexts(state.plan);
996
- const text = texts[Number(key) - 1];
997
- if (!text) return;
998
- if (!isEditorFocused(state.dockRoot) && !state.overlayOpen) return;
999
- preventDefault(e);
1000
- stopPropagation(e);
1001
- insertIntoComposer(state.inputActions, text);
1002
- }
1003
- }
1004
-
1005
- function handlePointerDown(event) {
1006
- const state = store.get();
1007
- if (!state.overlayOpen) return;
1008
- const target = event && event.target;
1009
- if (!target || typeof target.nodeType !== 'number') return;
1010
- try {
1011
- if (state.popoverRoot && state.popoverRoot.contains(target)) return;
1012
- if (state.triggerEl && state.triggerEl.contains(target)) return;
1013
- if (state.dockRoot && state.dockRoot.contains(target)) return;
1014
- } catch (error) {
1015
- return;
1016
- }
1017
- closeOverlay();
1018
- }
1019
-
1020
- // =====================================================================
1021
- // Slot components
1022
- // =====================================================================
1023
- // InputState / Session snapshots use selector hooks: always pass a function.
1024
- const selectInputDraft = function selectInputDraft(state) {
1025
- return state ? state.draft : undefined;
1026
- };
1027
- const selectInputPhase = function selectInputPhase(state) {
1028
- return state ? state.phase : undefined;
1029
- };
1030
-
1031
- function useInputSelector(props, selector) {
1032
- try {
1033
- if (props && typeof props.useInput === 'function') return props.useInput(selector);
1034
- } catch (error) {
1035
- return undefined;
1036
- }
1037
- return undefined;
1038
- }
1039
-
1040
- function readRunningValue(value) {
1041
- if (value === true || value === false) return value;
1042
- if (!value || typeof value !== 'object') return null;
1043
- if (typeof value.running === 'boolean') return value.running;
1044
- if (typeof value.status === 'string') {
1045
- if (value.status === 'running') return true;
1046
- if (value.status === 'idle') return false;
1047
- }
1048
- if (typeof value.phase === 'string') {
1049
- if (value.phase === 'running') return true;
1050
- if (value.phase === 'idle' || value.phase === 'done') return false;
1051
- }
1052
- return null;
1053
- }
1054
-
1055
- /**
1056
- * running: true/false when the host exposes it, null when unknown.
1057
- * Both hooks are invoked unconditionally so hook order stays stable.
1058
- */
1059
- function useSessionRunning(props) {
1060
- let direct = undefined;
1061
- try {
1062
- if (props && typeof props.useSession === 'function') {
1063
- direct = props.useSession(function selectRunning(session) {
1064
- return session && typeof session === 'object' ? session.running === true : undefined;
1065
- });
1066
- }
1067
- } catch (error) {
1068
- direct = undefined;
1069
- }
1070
- let mapped = undefined;
1071
- try {
1072
- if (props && typeof props.useSessionStatus === 'function') {
1073
- const sessionId = props.sessionId;
1074
- mapped = props.useSessionStatus(function selectStatus(snapshot) {
1075
- if (!snapshot) return undefined;
1076
- if (typeof snapshot.get === 'function') return snapshot.get(sessionId);
1077
- if (sessionId && snapshot[sessionId]) return snapshot[sessionId];
1078
- return undefined;
1079
- });
1080
- }
1081
- } catch (error) {
1082
- mapped = undefined;
1083
- }
1084
- const running = readRunningValue(direct);
1085
- if (running !== null) return running;
1086
- return readRunningValue(mapped);
1087
- }
1088
-
1089
- function Dock(props) {
1090
- const plan = useStoreSelector(selectPlan);
1091
- const dismissedKey = useStoreSelector(selectDismissedKey);
1092
- const overlayOpen = useStoreSelector(selectOverlayOpen);
1093
- const t = makeT(props);
1094
-
1095
- // Hook calls stay unconditional; values may be undefined before the input face mounts.
1096
- const draftValue = useInputSelector(props, selectInputDraft);
1097
- const phaseValue = useInputSelector(props, selectInputPhase);
1098
- const runningValue = useSessionRunning(props);
1099
- // `undefined` means unknown: the host exposes no usable running signal, so the
1100
- // polling fallback below takes over instead of relying on turn-end transitions.
1101
- const running = runningValue === true || runningValue === false ? runningValue : undefined;
1102
-
1103
- const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : undefined;
1104
- const inputActions = props && props.inputActions ? props.inputActions : null;
1105
- const draft = typeof draftValue === 'string' ? draftValue : null;
1106
- const phaseOk = phaseValue === undefined || phaseValue === 'plain';
1107
- const inputEmpty = draft === '' && phaseOk;
1108
-
1109
- const [turnTick, setTurnTick] = React.useState(0);
1110
- const previousRunningRef = React.useRef(running);
1111
- const turnEndedRef = React.useRef(false);
1112
- const runningRef = React.useRef(running);
1113
- runningRef.current = running;
1114
- const rootRef = React.useRef(null);
1115
- const suggestion = getSuggestion(plan);
1116
- const currentSuggestionKey = suggestionKey(plan, suggestion);
1117
- const showSuggestion = !!suggestion && inputEmpty && dismissedKey !== currentSuggestionKey;
1118
-
1119
- // Publish the facts the out-of-React keyboard handler needs.
1120
- React.useEffect(function publishRuntimeFacts() {
1121
- const current = store.get();
1122
- const patch = {};
1123
- if (current.sessionId !== sessionId) {
1124
- patch.sessionId = sessionId;
1125
- patch.plan = null;
1126
- patch.planSig = null;
1127
- patch.dismissedKey = null;
1128
- patch.overlayOpen = false;
1129
- patch.anchorRect = null;
1130
- patch.triggerEl = null;
1131
- }
1132
- if (current.inputActions !== inputActions) patch.inputActions = inputActions;
1133
- const draftText = draft === null ? '' : draft;
1134
- if (current.draftText !== draftText) patch.draftText = draftText;
1135
- if (current.inputEmpty !== inputEmpty) patch.inputEmpty = inputEmpty;
1136
- if (current.suggestionVisible !== showSuggestion) patch.suggestionVisible = showSuggestion;
1137
- if (current.dockRoot !== rootRef.current) patch.dockRoot = rootRef.current;
1138
- if (Object.keys(patch).length > 0) store.set(patch);
1139
- });
1140
-
1141
- // TEMP verification: auto-open the plan panel once after mount (static screenshot).
1142
- React.useEffect(function devOpenPopover() {
1143
- if (!DEV_OPEN_POPOVER || typeof window === 'undefined') return undefined;
1144
- const timer = window.setTimeout(function openIt() {
1145
- try {
1146
- const el = document.querySelector('[data-plan-completion-entry]');
1147
- if (el) openOverlay(el);
1148
- } catch (error) {
1149
- /* ignore */
1150
- }
1151
- }, 2500);
1152
- return function cleanup() {
1153
- window.clearTimeout(timer);
1154
- };
1155
- }, []);
1156
-
1157
- // Align the floating pill group with the composer card it floats above, so the
1158
- // suggestion entry shares the composer's left edge instead of the column edge.
1159
- React.useEffect(function alignWithComposer() {
1160
- if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
1161
- let frame = 0;
1162
- const measure = function measure() {
1163
- try {
1164
- const row = rootRef.current;
1165
- if (!row || typeof row.getBoundingClientRect !== 'function') return;
1166
- const rowRect = row.getBoundingClientRect();
1167
- if (rowRect.width <= 0) return;
1168
- const candidates = Array.from(document.querySelectorAll('[data-composer-card]'));
1169
- let card = null;
1170
- let bestDistance = Number.POSITIVE_INFINITY;
1171
- for (const node of candidates) {
1172
- const rect = node.getBoundingClientRect();
1173
- const gap = rect.top - rowRect.bottom;
1174
- const visible = rect.width > 100 && rect.height > 20;
1175
- // Prefer the composer card that sits right below this row (the mounted one).
1176
- if (!visible || gap < -24 || gap > 220) continue;
1177
- if (gap < bestDistance) {
1178
- bestDistance = gap;
1179
- card = rect;
1180
- }
1181
- }
1182
- if (!card) return;
1183
- const left = Math.max(0, Math.round(card.left - rowRect.left));
1184
- row.style.paddingLeft = left + 'px';
1185
- } catch (error) {
1186
- /* ignore */
1187
- }
1188
- };
1189
- const schedule = function schedule() {
1190
- if (frame) return;
1191
- try {
1192
- frame = window.requestAnimationFrame(function run() {
1193
- frame = 0;
1194
- measure();
1195
- });
1196
- } catch (error) {
1197
- frame = 0;
1198
- measure();
1199
- }
1200
- };
1201
- measure();
1202
- const timer = window.setTimeout(measure, 400);
1203
- const timer2 = window.setTimeout(measure, 1200);
1204
- const timer3 = window.setTimeout(measure, 2500);
1205
- window.addEventListener('resize', schedule, true);
1206
- let observer = null;
1207
- let cardObserver = null;
1208
- try {
1209
- if (typeof ResizeObserver === 'function') {
1210
- observer = new ResizeObserver(schedule);
1211
- observer.observe(document.documentElement);
1212
- // The composer card moves when the column width or the tool row changes.
1213
- const cardEl = document.querySelector('[data-composer-card]');
1214
- if (cardEl) {
1215
- cardObserver = new ResizeObserver(schedule);
1216
- cardObserver.observe(cardEl);
1217
- }
1218
- }
1219
- } catch (error) {
1220
- /* ignore */
1221
- }
1222
- return function cleanupAlign() {
1223
- try {
1224
- window.clearTimeout(timer);
1225
- window.clearTimeout(timer2);
1226
- window.clearTimeout(timer3);
1227
- window.removeEventListener('resize', schedule, true);
1228
- if (observer) observer.disconnect();
1229
- if (cardObserver) cardObserver.disconnect();
1230
- if (frame) window.cancelAnimationFrame(frame);
1231
- } catch (error) {
1232
- /* ignore */
1233
- }
1234
- };
1235
- }, [sessionId]);
1236
-
1237
- // Known running transitions bump turnTick; true -> false marks a turn end.
1238
- // While `running` is unknown no tick can ever fire, and the polling effect
1239
- // below keeps the plan state fresh instead.
1240
- React.useEffect(function trackTurnEnd() {
1241
- const previous = previousRunningRef.current;
1242
- previousRunningRef.current = running;
1243
- if (previous === undefined || running === undefined || previous === running) return;
1244
- turnEndedRef.current = previous === true && running === false;
1245
- setTurnTick(function bump(value) {
1246
- return value + 1;
1247
- });
1248
- }, [running]);
1249
-
1250
- // Mount / session switch: read once (silent when the route is absent).
1251
- React.useEffect(function loadPlanOnMount() {
1252
- if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1253
- // Distinguish the first mount from an actual sessionId change for the Host log.
1254
- const reason = lastPlanSessionId !== undefined && lastPlanSessionId !== sessionId ? 'session' : 'mount';
1255
- lastPlanSessionId = sessionId;
1256
- let cancelled = false;
1257
- const request = function request(kind, allowWarm) {
1258
- if (cancelled) return;
1259
- fetchPlanState(sessionId, kind, runningRef.current).then(function loaded(result) {
1260
- if (cancelled) return;
1261
- handlePlanFetch(sessionId, result);
1262
- if (result.plan) return;
1263
- // P0-1: session known to the app but never observed by this plugin (old session,
1264
- // plugin just enabled) — ask the Host to backfill from the durable session log.
1265
- if (allowWarm === false) return;
1266
- warmPlanState(sessionId).then(function warmed() {
1267
- if (cancelled || !warmed || !warmed.json) return;
1268
- const warmedPlan = normalizePlan(warmed.json);
1269
- if (warmedPlan) applyPlanResult(sessionId, warmedPlan);
1270
- });
1271
- });
1272
- };
1273
- const refresher = function refresh(kind) {
1274
- request(typeof kind === 'string' && kind !== '' ? kind : 'popover', false);
1275
- };
1276
- planRefresher = refresher;
1277
- request(reason, true);
1278
- return function cleanup() {
1279
- cancelled = true;
1280
- if (planRefresher === refresher) planRefresher = null;
1281
- };
1282
- }, [sessionId]);
1283
-
1284
- // Turn end: silent retries across the full chain (300..9000ms) so both the
1285
- // fast heuristic suggestion and the later model-refined one are picked up.
1286
- React.useEffect(function refreshAfterTurnEnd() {
1287
- if (turnTick === 0) return undefined;
1288
- if (!turnEndedRef.current) return undefined;
1289
- if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1290
- let cancelled = false;
1291
- let timer = 0;
1292
- let attempt = 0;
1293
- function scheduleNext() {
1294
- if (cancelled) return;
1295
- attempt += 1;
1296
- if (attempt >= TURN_RETRY_DELAYS.length) return;
1297
- timer = window.setTimeout(step, TURN_RETRY_DELAYS[attempt]);
1298
- }
1299
- function step() {
1300
- if (cancelled) return;
1301
- fetchPlanState(sessionId, 'turn-end', runningRef.current).then(
1302
- function loaded(result) {
1303
- if (cancelled) return;
1304
- handlePlanFetch(sessionId, result);
1305
- scheduleNext();
1306
- },
1307
- function failed() {
1308
- if (cancelled) return;
1309
- scheduleNext();
1310
- }
1311
- );
1312
- }
1313
- timer = window.setTimeout(step, TURN_RETRY_DELAYS[0]);
1314
- return function cleanup() {
1315
- cancelled = true;
1316
- if (timer) window.clearTimeout(timer);
1317
- };
1318
- }, [turnTick, sessionId]);
1319
-
1320
- // Fallback for an unknown `running`: poll every 4s while the page is visible
1321
- // (`reason=poll`) so a suggestion still appears within ~4-5s of a turn end.
1322
- React.useEffect(function pollWhileRunningUnknown() {
1323
- if (running !== undefined) return undefined;
1324
- if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1325
- let cancelled = false;
1326
- let timer = 0;
1327
- let inFlight = false;
1328
- function schedule() {
1329
- if (cancelled) return;
1330
- if (timer) window.clearTimeout(timer);
1331
- timer = window.setTimeout(request, POLL_MS);
1332
- }
1333
- function request() {
1334
- if (cancelled || inFlight) return;
1335
- if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
1336
- schedule();
1337
- return;
1338
- }
1339
- inFlight = true;
1340
- fetchPlanState(sessionId, 'poll', runningRef.current).then(
1341
- function loaded(result) {
1342
- inFlight = false;
1343
- if (cancelled) return;
1344
- handlePlanFetch(sessionId, result);
1345
- schedule();
1346
- },
1347
- function failed() {
1348
- inFlight = false;
1349
- if (!cancelled) schedule();
1350
- }
1351
- );
1352
- }
1353
- schedule();
1354
- return function cleanup() {
1355
- cancelled = true;
1356
- if (timer) window.clearTimeout(timer);
1357
- };
1358
- }, [running, sessionId]);
1359
-
1360
- // Watchdog for a known-but-static `running`: if no state request happened for
1361
- // 15s, issue one `reason=stale-poll` so suggestions still converge even when
1362
- // no turn-end transition is ever observed.
1363
- React.useEffect(function stalePollWatchdog() {
1364
- if (running === undefined) return undefined;
1365
- if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1366
- // A settled (idle) session must converge fast (~4s); during a running turn
1367
- // 15s is enough because the answer is not ready yet.
1368
- const interval = running === false ? POLL_MS : STALE_POLL_MS;
1369
- let cancelled = false;
1370
- let timer = 0;
1371
- function schedule() {
1372
- if (cancelled) return;
1373
- if (timer) window.clearTimeout(timer);
1374
- timer = window.setTimeout(check, interval);
1375
- }
1376
- function check() {
1377
- if (cancelled) return;
1378
- if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
1379
- schedule();
1380
- return;
1381
- }
1382
- if (Date.now() - lastStateRequestAt < interval) {
1383
- schedule();
1384
- return;
1385
- }
1386
- fetchPlanState(sessionId, 'stale-poll', runningRef.current).then(
1387
- function loaded(result) {
1388
- if (cancelled) return;
1389
- handlePlanFetch(sessionId, result);
1390
- schedule();
1391
- },
1392
- function failed() {
1393
- if (!cancelled) schedule();
1394
- }
1395
- );
1396
- }
1397
- schedule();
1398
- return function cleanup() {
1399
- cancelled = true;
1400
- if (timer) window.clearTimeout(timer);
1401
- };
1402
- }, [running, sessionId]);
1403
-
1404
- // Opening the popover refreshes state silently.
1405
- React.useEffect(function refreshOnOpen() {
1406
- if (!overlayOpen) return;
1407
- if (typeof planRefresher === 'function') planRefresher('popover');
1408
- }, [overlayOpen]);
1409
-
1410
- // The persistent entry is always rendered; with no plan it shows the bare glyph.
1411
- const label = plan ? versionLabel(plan) : '';
1412
- const children = [
1413
- h('style', { key: 'style', 'data-plan-completion-style': 'dock' }, DOCK_CSS)
1414
- ];
1415
- const dockChildren = [];
1416
-
1417
- // Floating suggestion pill on the left; the fixed-height row keeps its space
1418
- // when hidden so the composer never jumps. The plan pill on the right is a
1419
- // separate flex child and is always rendered, whatever the suggestion length.
1420
- const suggestionVisible = showSuggestion && !!suggestion;
1421
- if (suggestionVisible && suggestion) {
1422
- const alternatives = suggestion.alternates ? suggestion.alternates.length : 0;
1423
- const pillChildren = [
1424
- h('span', { key: 'icon', className: 'dpc-suggest-icon', 'aria-hidden': true }, '⏎'),
1425
- h('span', { key: 'hint', className: 'dpc-suggest-hint' }, t('suggest.hint')),
1426
- h('span', { key: 'sep', className: 'dpc-suggest-sep', 'aria-hidden': true }, '·'),
1427
- h(
1428
- 'span',
1429
- { key: 'text', className: 'dpc-suggest-text', title: suggestion.text },
1430
- '「' + suggestion.text + '」'
1431
- )
1432
- ];
1433
- if (alternatives > 0) {
1434
- pillChildren.push(
1435
- h('span', { key: 'alts', className: 'dpc-suggest-alts' }, t('suggest.alts', { count: alternatives + 1 }))
1436
- );
1437
- }
1438
- pillChildren.push(
1439
- h(
1440
- 'button',
1441
- {
1442
- key: 'dismiss',
1443
- type: 'button',
1444
- className: 'dpc-dismiss',
1445
- tabIndex: -1,
1446
- 'aria-label': t('suggest.dismiss'),
1447
- onMouseDown: preventDefault,
1448
- onClick: function onClickDismiss(event) {
1449
- stopPropagation(event);
1450
- dismissSuggestion();
1451
- }
1452
- },
1453
- '✕'
1454
- )
1455
- );
1456
- dockChildren.push(
1457
- h(
1458
- 'div',
1459
- {
1460
- key: 'suggest',
1461
- className: 'dpc-pill dpc-suggest-pill',
1462
- 'data-plan-completion-suggest': 'visible',
1463
- role: 'status',
1464
- 'aria-live': 'polite'
1465
- },
1466
- pillChildren
1467
- )
1468
- );
1469
- } else {
1470
- // No suggestion: show a dashed "hint" pill while the session has no plan yet so the
1471
- // bar stays visible (常驻) even in a brand-new / cleared conversation.
1472
- if (!plan) {
1473
- dockChildren.push(
1474
- h(
1475
- 'div',
1476
- { key: 'hint', className: 'dpc-pill dpc-hint-pill', role: 'status' },
1477
- h('span', { key: 'icon', className: 'dpc-suggest-icon', 'aria-hidden': true }, '▤'),
1478
- h('span', { key: 'text', className: 'dpc-suggest-text' }, t('suggest.noplan'))
1479
- )
1480
- );
1481
- } else {
1482
- // Flex spacer: keeps the plan pill pinned left without moving the row.
1483
- dockChildren.push(
1484
- h('span', {
1485
- key: 'suggest',
1486
- className: 'dpc-suggest-placeholder',
1487
- 'data-plan-completion-suggest': 'hidden',
1488
- 'aria-hidden': 'true'
1489
- })
1490
- );
1491
- }
1492
- }
1493
-
1494
- dockChildren.push(
1495
- h(
1496
- 'button',
1497
- {
1498
- key: 'entry',
1499
- type: 'button',
1500
- className: 'dpc-pill dpc-entry' + (suggestionVisible ? ' dpc-entry-strong' : ''),
1501
- 'data-plan-completion-entry': 'true',
1502
- 'aria-haspopup': 'dialog',
1503
- 'aria-expanded': overlayOpen ? 'true' : 'false',
1504
- 'aria-label': t('entry.aria'),
1505
- title: label ? label + (plan && plan.intent ? ' · ' + plan.intent : '') : t('entry.title'),
1506
- onMouseDown: preventDefault,
1507
- onClick: function onClickEntry(event) {
1508
- stopPropagation(event);
1509
- toggleOverlay(event && event.currentTarget ? event.currentTarget : null);
1510
- }
1511
- },
1512
- h('span', { key: 'icon', className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1513
- label
1514
- ? h('span', { key: 'version', className: 'dpc-entry-version' }, label)
1515
- : h('span', { key: 'label', className: 'dpc-entry-label' }, t('entry.label'))
1516
- )
1517
- );
1518
-
1519
- children.push(
1520
- h(
1521
- 'div',
1522
- {
1523
- key: 'root',
1524
- ref: rootRef,
1525
- className: 'dpc-root',
1526
- 'data-plan-completion': 'dock',
1527
- 'data-plan-completion-version': label
1528
- },
1529
- dockChildren
1530
- )
1531
- );
1532
-
1533
- return h(React.Fragment, null, children);
1534
- }
1535
-
1536
- function computePopoverPosition(rect) {
1537
- const viewportWidth = (typeof window !== 'undefined' && window.innerWidth) || 1024;
1538
- const viewportHeight = (typeof window !== 'undefined' && window.innerHeight) || 768;
1539
- const width = Math.max(300, Math.min(440, viewportWidth - 24));
1540
- const style = {
1541
- position: 'fixed',
1542
- right: 12,
1543
- bottom: 12,
1544
- width: width,
1545
- maxHeight: Math.max(180, Math.min(520, viewportHeight - 24)),
1546
- overflow: 'auto'
1547
- };
1548
- if (rect && typeof rect.top === 'number' && typeof rect.right === 'number') {
1549
- style.bottom = Math.max(12, viewportHeight - rect.top + 8);
1550
- style.right = Math.max(12, Math.min(viewportWidth - rect.right, viewportWidth - width - 12));
1551
- style.maxHeight = Math.max(160, Math.min(520, rect.top - 16));
1552
- }
1553
- return style;
1554
- }
1555
-
1556
- function renderCloseButton(t) {
1557
- return h(
1558
- 'button',
1559
- {
1560
- key: 'close',
1561
- type: 'button',
1562
- className: 'dpc-pop-close',
1563
- 'aria-label': t('popover.close'),
1564
- onMouseDown: preventDefault,
1565
- onClick: function onClickClose(event) {
1566
- stopPropagation(event);
1567
- closeOverlay();
1568
- }
1569
- },
1570
- '✕'
1571
- );
1572
- }
1573
-
1574
- function renderPopoverContent(plan, inputActions, t) {
1575
- const children = [];
1576
- if (!plan) {
1577
- children.push(
1578
- h(
1579
- 'div',
1580
- { key: 'head', className: 'dpc-pop-head' },
1581
- h('span', { className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1582
- h('span', { className: 'dpc-pop-title' }, t('popover.title')),
1583
- renderCloseButton(t)
1584
- )
1585
- );
1586
- children.push(h('div', { key: 'empty', className: 'dpc-empty' }, t('popover.empty')));
1587
- return [children[0], h('div', { key: 'body', className: 'dpc-pop-body' }, children.slice(1))];
1588
- }
1589
-
1590
- children.push(
1591
- h(
1592
- 'div',
1593
- { key: 'head', className: 'dpc-pop-head' },
1594
- h('span', { className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1595
- h('span', { className: 'dpc-pop-title' }, t('popover.title')),
1596
- h('span', { className: 'dpc-pop-ver' }, versionLabel(plan)),
1597
- h('span', { className: 'dpc-pop-chip', 'data-status': plan.status }, t('status.' + plan.status)),
1598
- renderCloseButton(t)
1599
- )
1600
- );
1601
-
1602
- if (plan.stale) {
1603
- children.push(h('div', { key: 'stale', className: 'dpc-stale', role: 'status' }, t('popover.stale')));
1604
- }
1605
-
1606
- if (plan.intent) {
1607
- children.push(
1608
- h(
1609
- 'div',
1610
- { key: 'intent', className: 'dpc-section' },
1611
- h('div', { className: 'dpc-label' }, t('popover.intent')),
1612
- h('div', { className: 'dpc-text' }, plan.intent)
1613
- )
1614
- );
1615
- }
1616
-
1617
- if (plan.steps.length > 0) {
1618
- children.push(
1619
- h(
1620
- 'div',
1621
- { key: 'steps', className: 'dpc-section' },
1622
- h('div', { className: 'dpc-label' }, t('popover.steps')),
1623
- h(
1624
- 'ol',
1625
- { className: 'dpc-steps' },
1626
- plan.steps.map(function renderStep(step) {
1627
- const glyph =
1628
- step.status === 'done' ? '✓' : step.status === 'doing' ? '●' : step.status === 'dropped' ? '✕' : '○';
1629
- const mainChildren = [h('span', { key: 'title', className: 'dpc-step-title' }, step.title)];
1630
- if (step.evidence.length > 0) {
1631
- mainChildren.push(
1632
- h('span', { key: 'evidence', className: 'dpc-evidence' }, step.evidence.join(' · '))
1633
- );
1634
- }
1635
- return h(
1636
- 'li',
1637
- { key: step.id + ':' + step.title, className: 'dpc-step', 'data-status': step.status },
1638
- h(
1639
- 'span',
1640
- { className: 'dpc-glyph', role: 'img', 'aria-label': t('step.' + step.status) },
1641
- glyph
1642
- ),
1643
- h('span', { className: 'dpc-step-main' }, mainChildren)
1644
- );
1645
- })
1646
- )
1647
- )
1648
- );
1649
- }
1650
-
1651
- if (plan.reason) {
1652
- children.push(
1653
- h(
1654
- 'div',
1655
- { key: 'reason', className: 'dpc-section' },
1656
- h('div', { className: 'dpc-label' }, t('popover.reason')),
1657
- h('div', { className: 'dpc-reason' }, plan.reason)
1658
- )
1659
- );
1660
- }
1661
-
1662
- const suggestion = getSuggestion(plan);
1663
- if (suggestion) {
1664
- children.push(
1665
- h(
1666
- 'div',
1667
- { key: 'suggestion', className: 'dpc-section dpc-suggest-card' },
1668
- h('div', { className: 'dpc-label' }, t('popover.suggestion')),
1669
- h('div', { className: 'dpc-suggest-line' }, suggestion.text)
1670
- )
1671
- );
1672
- } else if (plan.status === 'active') {
1673
- const fillText = planFillText(plan);
1674
- if (fillText) {
1675
- children.push(
1676
- h(
1677
- 'div',
1678
- { key: 'suggestion', className: 'dpc-section dpc-suggest-card' },
1679
- h('div', { className: 'dpc-label' }, t('popover.suggestion')),
1680
- h('div', { className: 'dpc-suggest-line' }, fillText)
1681
- )
1682
- );
1683
- }
1684
- }
1685
-
1686
- if (plan.updatedAt) {
1687
- children.push(
1688
- h('div', { key: 'updated', className: 'dpc-updated' }, t('popover.updated', { time: formatTime(plan.updatedAt) }))
1689
- );
1690
- }
1691
-
1692
- const fillText = planFillText(plan);
1693
- const canWrite = !!inputActions;
1694
- children.push(
1695
- h(
1696
- 'div',
1697
- { key: 'actions', className: 'dpc-actions' },
1698
- h(
1699
- 'button',
1700
- {
1701
- type: 'button',
1702
- className: 'dpc-btn',
1703
- 'data-primary': 'true',
1704
- disabled: !canWrite || fillText === '',
1705
- onMouseDown: preventDefault,
1706
- onClick: function onClickFill(event) {
1707
- stopPropagation(event);
1708
- if (replaceComposerDraft(inputActions, fillText)) closeOverlay();
1709
- }
1710
- },
1711
- t('popover.fill')
1712
- ),
1713
- h(
1714
- 'button',
1715
- {
1716
- type: 'button',
1717
- className: 'dpc-btn',
1718
- disabled: !canWrite,
1719
- onMouseDown: preventDefault,
1720
- onClick: function onClickSync(event) {
1721
- stopPropagation(event);
1722
- const prompt = buildSyncPrompt(plan);
1723
- if (replaceComposerDraft(inputActions, prompt)) closeOverlay();
1724
- }
1725
- },
1726
- t('popover.sync')
1727
- ),
1728
- h(
1729
- 'button',
1730
- {
1731
- type: 'button',
1732
- className: 'dpc-btn',
1733
- onMouseDown: preventDefault,
1734
- onClick: function onClickClose(event) {
1735
- stopPropagation(event);
1736
- closeOverlay();
1737
- }
1738
- },
1739
- t('popover.close')
1740
- )
1741
- )
1742
- );
1743
-
1744
- // v2 skin: regroup flat children into header / scrollable body / footer.
1745
- if (children.length >= 2) {
1746
- const head = children.shift();
1747
- let updated = null;
1748
- const last = children.length ? children[children.length - 1] : null;
1749
- if (last && last.props && last.props.className === 'dpc-updated') updated = children.pop();
1750
- const actions = children.pop();
1751
- if (actions) {
1752
- return [
1753
- head,
1754
- h('div', { key: 'body', className: 'dpc-pop-body' }, children),
1755
- h('div', { key: 'foot', className: 'dpc-pop-foot' }, updated || null, actions)
1756
- ];
1757
- }
1758
- }
1759
-
1760
- return children;
1761
- }
1762
-
1763
- function PlanPopover(props) {
1764
- const state = useStoreSelector(selectAll);
1765
- const t = makeT(props);
1766
- const rootRef = React.useRef(null);
1767
- const open = state.overlayOpen === true;
1768
- const [heroPos, setHeroPos] = React.useState(null);
1769
-
1770
- // P0-2 hero fallback: when no session-bound dock is mounted (new conversation / hero,
1771
- // or a cleared conversation), float a small «▤ 计划» entry above the composer so the
1772
- // entry is always reachable.
1773
- React.useEffect(function trackHeroEntry() {
1774
- if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
1775
- let timer = 0;
1776
- const scan = function scan() {
1777
- try {
1778
- if (!DEV_HERO_PREVIEW && document.querySelector('[data-plan-completion="dock"]')) {
1779
- setHeroPos(null);
1780
- return;
1781
- }
1782
- const cards = Array.from(document.querySelectorAll('[data-composer-card]'));
1783
- let card = null;
1784
- for (const node of cards) {
1785
- const rect = node.getBoundingClientRect();
1786
- if (rect.width > 100 && rect.height > 20 && rect.top > 80) {
1787
- card = rect;
1788
- break;
1789
- }
1790
- }
1791
- if (!card) {
1792
- setHeroPos(null);
1793
- return;
1794
- }
1795
- const next = { top: Math.max(8, Math.round(card.top - 34)), left: Math.round(card.left) };
1796
- setHeroPos(function keep(previous) {
1797
- return previous && previous.top === next.top && previous.left === next.left ? previous : next;
1798
- });
1799
- } catch (error) {
1800
- /* ignore */
1801
- }
1802
- };
1803
- scan();
1804
- timer = window.setInterval(scan, 700);
1805
- window.addEventListener('resize', scan);
1806
- return function cleanup() {
1807
- if (timer) window.clearInterval(timer);
1808
- window.removeEventListener('resize', scan);
1809
- };
1810
- }, []);
1811
-
1812
- React.useEffect(function publishPopoverRoot() {
1813
- store.set({ popoverRoot: open ? rootRef.current : null });
1814
- return function cleanup() {
1815
- if (store.get().popoverRoot === rootRef.current) store.set({ popoverRoot: null });
1816
- };
1817
- }, [open]);
1818
-
1819
- const styleTag = h('style', { key: 'style', 'data-plan-completion-style': 'overlay' }, POPOVER_CSS + HERO_CSS);
1820
- const heroEntry = heroPos
1821
- ? h(
1822
- 'button',
1823
- {
1824
- key: 'hero',
1825
- type: 'button',
1826
- className: 'dpc-hero-entry',
1827
- 'data-plan-completion': 'hero-entry',
1828
- style: { top: heroPos.top + 'px', left: heroPos.left + 'px' },
1829
- 'aria-haspopup': 'dialog',
1830
- 'aria-expanded': open ? 'true' : 'false',
1831
- title: t('entry.title'),
1832
- onMouseDown: preventDefault,
1833
- onClick: function onClickHero(event) {
1834
- stopPropagation(event);
1835
- openOverlay(event && event.currentTarget ? event.currentTarget : null);
1836
- }
1837
- },
1838
- h('span', { key: 'icon', className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1839
- h('span', { key: 'label' }, t('entry.label'))
1840
- )
1841
- : null;
1842
-
1843
- if (!open) return h(React.Fragment, null, styleTag, heroEntry);
1844
-
1845
- const position = computePopoverPosition(state.anchorRect);
1846
- return h(
1847
- React.Fragment,
1848
- null,
1849
- styleTag,
1850
- heroEntry,
1851
- h(
1852
- 'div',
1853
- {
1854
- key: 'dialog',
1855
- ref: rootRef,
1856
- className: 'dpc-popover',
1857
- role: 'dialog',
1858
- 'aria-label': t('popover.title'),
1859
- 'data-plan-completion': 'popover',
1860
- style: position
1861
- },
1862
- renderPopoverContent(state.plan, state.inputActions, t)
1863
- )
1864
- );
1865
- }
1866
-
1867
- // =====================================================================
1868
- // Plugin entry
1869
- // =====================================================================
1870
- return {
1871
- inject: ['slots', 'locale'],
1872
- apply(ctx) {
1873
- try {
1874
- ctx.effect(function registerDictionaries() {
1875
- return ctx.locale.register(NS, { zh: MESSAGES.zh, en: MESSAGES.en });
1876
- }, 'plan-completion: dictionaries');
1877
- } catch (error) {
1878
- /* locale is optional for rendering: makeT falls back to the local dictionary */
1879
- }
1880
- try {
1881
- hostT = ctx.locale.bind(NS);
1882
- } catch (error) {
1883
- hostT = null;
1884
- }
1885
- try {
1886
- pluginThemeService = ctx.get('theme');
1887
- } catch (error) {
1888
- pluginThemeService = null;
1889
- }
1890
-
1891
- ctx.slots.inject(DOCK_SLOT, function registerDock() {
1892
- return ctx.slots.register(
1893
- { name: DOCK_SLOT, id: DOCK_ID, order: DOCK_ORDER, locale: NS },
1894
- Dock
1895
- );
1896
- });
1897
-
1898
- ctx.slots.inject(OVERLAY_SLOT, function registerOverlay() {
1899
- return ctx.slots.register({ name: OVERLAY_SLOT, id: OVERLAY_ID, order: OVERLAY_ORDER }, PlanPopover);
1900
- });
1901
-
1902
- ctx.effect(function installKeyboard() {
1903
- if (typeof window === 'undefined') return undefined;
1904
- const onKeyDown = function onKeyDown(event) {
1905
- handleKeyDown(event);
1906
- };
1907
- window.addEventListener('keydown', onKeyDown, true);
1908
- return function removeKeyboard() {
1909
- window.removeEventListener('keydown', onKeyDown, true);
1910
- };
1911
- }, 'plan-completion: keyboard');
1912
-
1913
- ctx.effect(function installOutsideClick() {
1914
- if (typeof document === 'undefined') return undefined;
1915
- const onPointerDown = function onPointerDown(event) {
1916
- handlePointerDown(event);
1917
- };
1918
- document.addEventListener('pointerdown', onPointerDown, true);
1919
- return function removeOutsideClick() {
1920
- document.removeEventListener('pointerdown', onPointerDown, true);
1921
- };
1922
- }, 'plan-completion: outside-click');
1923
-
1924
- // Diagnostics: prove which client build is live and whether the capsule UI rendered.
1925
- try {
1926
- if (typeof window !== 'undefined') {
1927
- const styleOf = function styleOf(el) {
1928
- try {
1929
- if (!el || typeof getComputedStyle !== 'function') return null;
1930
- const cs = getComputedStyle(el);
1931
- const box = el.getBoundingClientRect();
1932
- return {
1933
- left: Math.round(box.left),
1934
- top: Math.round(box.top),
1935
- w: Math.round(box.width),
1936
- h: Math.round(box.height),
1937
- display: cs.display,
1938
- bg: cs.backgroundColor,
1939
- border: cs.borderTopWidth + ' ' + cs.borderTopStyle + ' ' + cs.borderTopColor,
1940
- radius: cs.borderTopLeftRadius,
1941
- shadow: cs.boxShadow === 'none' ? 'none' : 'yes',
1942
- font: cs.fontSize + '/' + cs.lineHeight,
1943
- text: String(el.textContent || '').replace(/\s+/g, ' ').trim().slice(0, 80),
1944
- };
1945
- } catch (error) {
1946
- return { error: String(error) };
1947
- }
1948
- };
1949
- const postDiag = function postDiag(phase) {
1950
- try {
1951
- const pill = document.querySelector('.dpc-pill');
1952
- const entry = document.querySelector('[data-plan-completion-entry]');
1953
- const row = document.querySelector('[data-plan-completion="dock"]');
1954
- const suggest = document.querySelector('.dpc-suggest-pill');
1955
- const hero = document.querySelector('[data-plan-completion="hero-entry"]');
1956
- fetch('/plan-completion/diag', {
1957
- method: 'POST',
1958
- credentials: 'same-origin',
1959
- headers: { 'content-type': 'application/json' },
1960
- body: JSON.stringify({
1961
- phase: phase,
1962
- build: CLIENT_BUILD,
1963
- hasPill: !!pill,
1964
- hasEntry: !!entry,
1965
- entryText: entry ? String(entry.textContent || '').slice(0, 40) : null,
1966
- suggestVisible: !!suggest,
1967
- dockPresent: !!row,
1968
- heroVisible: !!hero,
1969
- heroStyle: styleOf(hero),
1970
- rowHeight: row ? Math.round(row.getBoundingClientRect().height) : null,
1971
- rowStyle: styleOf(row),
1972
- suggestStyle: styleOf(suggest),
1973
- entryStyle: styleOf(entry),
1974
- composerStyle: styleOf(document.querySelector('[data-composer-card]')),
1975
- href: String(window.location.href).slice(0, 120),
1976
- }),
1977
- }).catch(function () {});
1978
- } catch (error) {
1979
- /* ignore */
1980
- }
1981
- };
1982
- postDiag('module-load');
1983
- window.setTimeout(function later() { postDiag('dom-check'); }, 2500);
1984
- }
1985
- } catch (error) {
1986
- /* ignore */
1987
- }
1988
- }
1989
- };
1990
- }
1991
- });
1992
-
1993
- // hot-reload marker 2026-11-01T13:57Z
1994
-
1
+ /**
2
+ * dsh-plan-completion — Client (browser) half.
3
+ *
4
+ * Single browser artifact loaded through window.__ModuleLoader__:
5
+ * - `conversation.input.dock` : full-width suggestion row + persistent plan entry, above the composer card
6
+ * - `shell.overlay` : anchored plan popover
7
+ * - window keydown capture : Tab / Esc / Alt+1..3 / Ctrl+Alt+P
8
+ *
9
+ * Constraints kept on purpose (see task-3): no JSX, only `require('react')`,
10
+ * theme colours come from `--dsw-alias-*` tokens only, no host DOM writes,
11
+ * no document.body manipulation.
12
+ *
13
+ * All places that depend on P0 contracts are isolated in one function each:
14
+ * getPlanState() / fetchPlanState() -> plan state source (HTTP half)
15
+ * insertIntoComposer() -> Tab fill (captureInsertion + insertText)
16
+ * replaceComposerDraft() -> whole-draft writes (setDraft)
17
+ * isEditorFocused() / hasOpenComposerMenu() -> keyboard gate heuristics
18
+ */
19
+
20
+ window.__ModuleLoader__.load({
21
+ id: 'dsh-plan-completion',
22
+ factory(require) {
23
+ const React = require('react');
24
+ const h = React.createElement;
25
+
26
+ // =====================================================================
27
+ // Constants
28
+ // =====================================================================
29
+ const NS = 'plan-completion';
30
+ // Full-width entries above the composer card (purpose: "Full-width entries above the composer card").
31
+ const DOCK_SLOT = 'conversation.input.dock';
32
+ const OVERLAY_SLOT = 'shell.overlay';
33
+ const DOCK_ID = 'plan-completion-dock';
34
+ const OVERLAY_ID = 'plan-completion-popover';
35
+ const DOCK_ORDER = 30;
36
+ const OVERLAY_ORDER = 50;
37
+ const STATE_PATH = '/plan-completion/state';
38
+ // Turn-end refresh attempts (ms). The Host may publish a fast heuristic
39
+ // suggestion first and refine it with the model a few seconds later, so the
40
+ // whole chain runs to 9s; planSignature de-dupes unchanged payloads.
41
+ const TURN_RETRY_DELAYS = [300, 800, 1600, 3000, 6000, 9000];
42
+ // Fallback cadence while the host does not expose a usable `running` value.
43
+ const POLL_MS = 4000;
44
+ // Watchdog cadence for a known-but-static `running`: no state request for this
45
+ // long means suggestions may be stuck, so issue one `reason=stale-poll`.
46
+ const STALE_POLL_MS = 15000;
47
+ const STEP_STATUSES = ['todo', 'doing', 'done', 'dropped'];
48
+ // Client build marker surfaced to the Host diagnostic route (proves which bundle is live).
49
+ const CLIENT_BUILD = 'capsule-2026-11-01';
50
+ // TEMP verification flag: auto-open the plan panel shortly after mount so a static
51
+ // screenshot can be taken without synthetic clicks. Set back to false before release.
52
+ const DEV_OPEN_POPOVER = false;
53
+ // TEMP verification flag: render the hero fallback entry even when a dock exists.
54
+ const DEV_HERO_PREVIEW = false;
55
+
56
+ /**
57
+ * Optional static plan state for local/preview development:
58
+ * set `window.__DEV_PLAN__ = { ...plan }` in the page console and it wins
59
+ * over the HTTP adapter. Kept out of any production path.
60
+ */
61
+ const DEV_PLAN = (typeof globalThis !== 'undefined' && globalThis.__DEV_PLAN__) || null;
62
+
63
+ // =====================================================================
64
+ // i18n (zh primary, en secondary). Registered through ctx.locale.
65
+ // =====================================================================
66
+ const MESSAGES = {
67
+ zh: {
68
+ 'entry.aria': '打开会话计划面板',
69
+ 'entry.title': '会话计划',
70
+ 'entry.label': '计划',
71
+ 'suggest.noplan': '发送一条消息后自动生成计划与建议',
72
+ 'suggest.hint': 'Tab 填入',
73
+ 'suggest.alts': 'Alt+1..{count}',
74
+ 'suggest.dismiss': '关闭本次建议',
75
+ 'popover.title': '会话计划',
76
+ 'popover.intent': '目标',
77
+ 'popover.steps': '步骤',
78
+ 'popover.reason': '最近更新',
79
+ 'popover.suggestion': '下一步建议',
80
+ 'popover.fill': '填入草稿',
81
+ 'popover.sync': '同步给 Agent',
82
+ 'popover.close': '关闭',
83
+ 'popover.empty': '暂无计划',
84
+ 'popover.updated': '更新于 {time}',
85
+ 'popover.stale': '计划可能已过期,将在下一回合重建',
86
+ 'status.active': '进行中',
87
+ 'status.completed': '已完成',
88
+ 'status.archived': '已归档',
89
+ 'step.todo': '待办',
90
+ 'step.doing': '进行中',
91
+ 'step.done': '已完成',
92
+ 'step.dropped': '已放弃',
93
+ 'sync.prefix': '请按当前会话计划继续推进。',
94
+ 'sync.intent': '目标:{value}。',
95
+ 'sync.done': '已完成:{value}。',
96
+ 'sync.doing': '进行中:{value}。',
97
+ 'sync.todo': '待办:{value}。',
98
+ 'sync.reason': '最近更新:{value}。',
99
+ 'sync.next': '下一步建议:{value}。',
100
+ 'sync.fallback': '请先确认当前进度,再继续推进。'
101
+ },
102
+ en: {
103
+ 'entry.aria': 'Open the session plan panel',
104
+ 'entry.title': 'Session plan',
105
+ 'entry.label': 'Plan',
106
+ 'suggest.noplan': 'Send a message to generate a plan and suggestion',
107
+ 'suggest.hint': 'Tab to fill',
108
+ 'suggest.alts': 'Alt+1..{count}',
109
+ 'suggest.dismiss': 'Dismiss this suggestion',
110
+ 'popover.title': 'Session plan',
111
+ 'popover.intent': 'Intent',
112
+ 'popover.steps': 'Steps',
113
+ 'popover.reason': 'Latest update',
114
+ 'popover.suggestion': 'Next suggestion',
115
+ 'popover.fill': 'Fill draft',
116
+ 'popover.sync': 'Sync to Agent',
117
+ 'popover.close': 'Close',
118
+ 'popover.empty': 'No plan yet',
119
+ 'popover.updated': 'Updated {time}',
120
+ 'popover.stale': 'The plan may be stale and will be rebuilt next turn',
121
+ 'status.active': 'Active',
122
+ 'status.completed': 'Completed',
123
+ 'status.archived': 'Archived',
124
+ 'step.todo': 'Todo',
125
+ 'step.doing': 'In progress',
126
+ 'step.done': 'Done',
127
+ 'step.dropped': 'Dropped',
128
+ 'sync.prefix': 'Please continue based on the current session plan.',
129
+ 'sync.intent': 'Intent: {value}.',
130
+ 'sync.done': 'Done: {value}.',
131
+ 'sync.doing': 'In progress: {value}.',
132
+ 'sync.todo': 'Pending: {value}.',
133
+ 'sync.reason': 'Latest update: {value}.',
134
+ 'sync.next': 'Next suggestion: {value}.',
135
+ 'sync.fallback': 'Please confirm the current progress before continuing.'
136
+ }
137
+ };
138
+
139
+ let hostT = null;
140
+ // Test-only hook: the Host's /plan-completion/theme route puts `debugTheme` into the
141
+ // state payload so an automated dark/light check can switch the app theme.
142
+ let pluginThemeService = null;
143
+ let appliedDebugTheme = null;
144
+
145
+ function reportTheme(phase, detail) {
146
+ try {
147
+ fetch('/plan-completion/diag', {
148
+ method: 'POST',
149
+ credentials: 'same-origin',
150
+ headers: { 'content-type': 'application/json' },
151
+ body: JSON.stringify(Object.assign({ phase: phase, build: CLIENT_BUILD }, detail || {})),
152
+ }).catch(function () {});
153
+ } catch (error) {
154
+ /* ignore */
155
+ }
156
+ }
157
+
158
+ function applyDebugTheme(mode) {
159
+ if (!mode || appliedDebugTheme === mode) return;
160
+ if (!pluginThemeService) {
161
+ reportTheme('theme-error', { error: 'no-theme-service', mode: mode });
162
+ return;
163
+ }
164
+ try {
165
+ pluginThemeService.setTheme(mode);
166
+ appliedDebugTheme = mode;
167
+ reportTheme('theme-applied', { mode: mode });
168
+ } catch (error) {
169
+ reportTheme('theme-error', { error: String(error), mode: mode });
170
+ }
171
+ }
172
+
173
+ function detectLang() {
174
+ try {
175
+ if (typeof navigator === 'undefined' || !navigator) return 'zh';
176
+ const value = navigator.language || (navigator.languages && navigator.languages[0]);
177
+ if (typeof value === 'string' && /^en/i.test(value)) return 'en';
178
+ } catch (error) {
179
+ /* ignore */
180
+ }
181
+ return 'zh';
182
+ }
183
+
184
+ const FALLBACK_LANG = detectLang();
185
+
186
+ function fallbackT(key, vars) {
187
+ const dict = MESSAGES[FALLBACK_LANG] || MESSAGES.zh;
188
+ let text = dict[key];
189
+ if (typeof text !== 'string') text = MESSAGES.zh[key];
190
+ if (typeof text !== 'string') text = key;
191
+ if (vars && typeof vars === 'object') {
192
+ text = text.replace(/\{(\w+)\}/g, function replace(match, name) {
193
+ return vars[name] === undefined ? match : String(vars[name]);
194
+ });
195
+ }
196
+ return text;
197
+ }
198
+
199
+ function makeT(props) {
200
+ const propT = props && typeof props.t === 'function' ? props.t : null;
201
+ return function translate(key, vars) {
202
+ const bound = propT || hostT;
203
+ if (bound) {
204
+ try {
205
+ const value = bound(key, vars);
206
+ if (typeof value === 'string' && value !== key) return value;
207
+ } catch (error) {
208
+ /* fall through to the local dictionary */
209
+ }
210
+ }
211
+ return fallbackT(key, vars);
212
+ };
213
+ }
214
+
215
+ // =====================================================================
216
+ // Styles — theme tokens only (host light/dark switching is automatic).
217
+ // =====================================================================
218
+ const DOCK_CSS = [
219
+ // Fixed-height full-width row directly above the composer card: a floating
220
+ // suggestion pill on the left and the persistent plan pill on the right.
221
+ // The row keeps its height without a suggestion so the composer never jumps.
222
+ '.dpc-root{display:flex;align-items:center;gap:8px;width:100%;min-width:0;height:36px;box-sizing:border-box;padding:2px 12px 8px 12px;pointer-events:auto;font-size:12px;line-height:18px}',
223
+ '.dpc-suggest-placeholder{flex:0 0 0;width:0;min-width:0;height:0;overflow:hidden}',
224
+ '.dpc-pill{display:inline-flex;align-items:center;gap:6px;box-sizing:border-box;min-width:0;height:28px;padding:0 12px;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;max-width:100%;box-shadow:0 2px 10px rgba(0,0,0,.08)}',
225
+ '.dpc-suggest-pill{flex:0 1 auto;min-width:0;overflow:hidden}',
226
+ '.dpc-suggest-pill:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
227
+ '.dpc-suggest-icon{flex:none;color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:12px}',
228
+ '.dpc-suggest-hint{flex:none;color:var(--dsw-alias-label-secondary);white-space:nowrap}',
229
+ '.dpc-suggest-sep{flex:none;color:var(--dsw-alias-label-tertiary)}',
230
+ '.dpc-suggest-text{flex:0 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-primary)}',
231
+ '.dpc-suggest-alts{flex:none;color:var(--dsw-alias-label-tertiary);font-size:11px;white-space:nowrap}',
232
+ '.dpc-dismiss{flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:11px;line-height:16px;padding:1px 4px;border-radius:999px;cursor:pointer}',
233
+ '.dpc-dismiss:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
234
+ '.dpc-entry{flex:none;margin-left:0;cursor:pointer;opacity:.85;transition:opacity .12s ease}',
235
+ '.dpc-entry:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));opacity:1}',
236
+ '.dpc-entry-strong{opacity:1}',
237
+ '.dpc-entry[aria-expanded="true"]{opacity:1}',
238
+ '.dpc-entry-icon{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:11px}',
239
+ '.dpc-entry-version{color:var(--dsw-alias-label-secondary);white-space:nowrap;font-variant-numeric:tabular-nums}',
240
+ // Always-on state: when the current session has no plan yet (new / cleared
241
+ // conversation), the row still renders with a hint so the bar never vanishes.
242
+ '.dpc-hint-pill{flex:0 1 auto;min-width:0;overflow:hidden;color:var(--dsw-alias-label-tertiary);border-style:dashed;box-shadow:none;background:transparent}',
243
+ '.dpc-hint-pill .dpc-suggest-text{color:var(--dsw-alias-label-tertiary)}',
244
+ '.dpc-entry-label{color:var(--dsw-alias-label-secondary);white-space:nowrap}'
245
+ ].join('\n');
246
+
247
+ const POPOVER_CSS = [
248
+ '.dpc-popover{display:flex;flex-direction:column;gap:8px;box-sizing:border-box;padding:12px;border:1px solid var(--dsw-alias-border-l1);border-radius:10px;background:var(--dsw-alias-bg-overlay, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}',
249
+ '.dpc-pop-head{display:flex;align-items:center;gap:8px;min-width:0}',
250
+ '.dpc-pop-title{flex:none;font-size:13px;font-weight:600;line-height:20px;color:var(--dsw-alias-label-primary)}',
251
+ '.dpc-pop-ver{flex:none;font-size:12px;color:var(--dsw-alias-label-tertiary)}',
252
+ '.dpc-pop-chip{flex:none;padding:0 6px;border:1px solid var(--dsw-alias-border-l1);border-radius:8px;font-size:11px;line-height:16px;color:var(--dsw-alias-label-secondary)}',
253
+ '.dpc-pop-chip[data-status="completed"]{color:var(--dsw-alias-state-success-primary);border-color:var(--dsw-alias-state-success-primary)}',
254
+ '.dpc-pop-chip[data-status="active"]{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
255
+ '.dpc-pop-chip[data-status="archived"]{color:var(--dsw-alias-label-tertiary)}',
256
+ '.dpc-pop-close{margin-left:auto;flex:none;border:0;background:transparent;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px;padding:0 4px;border-radius:4px;cursor:pointer}',
257
+ '.dpc-pop-close:hover{color:var(--dsw-alias-label-primary);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
258
+ '.dpc-section{display:flex;flex-direction:column;gap:2px;min-width:0}',
259
+ '.dpc-label{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
260
+ '.dpc-text{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);white-space:pre-wrap;word-break:break-word}',
261
+ '.dpc-stale{font-size:11px;line-height:16px;color:var(--dsw-alias-state-warn-primary)}',
262
+ '.dpc-steps{display:flex;flex-direction:column;gap:6px;margin:0;padding:0;list-style:none}',
263
+ '.dpc-step{display:flex;align-items:flex-start;gap:8px;min-width:0;font-size:13px;line-height:20px}',
264
+ '.dpc-glyph{flex:none;width:16px;text-align:center;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}',
265
+ '.dpc-step[data-status="done"] .dpc-glyph{color:var(--dsw-alias-state-success-primary)}',
266
+ '.dpc-step[data-status="doing"] .dpc-glyph{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
267
+ '.dpc-step-main{display:flex;flex-direction:column;min-width:0;flex:1}',
268
+ '.dpc-step-title{color:var(--dsw-alias-label-primary);word-break:break-word}',
269
+ '.dpc-step[data-status="dropped"] .dpc-step-title{color:var(--dsw-alias-label-tertiary);text-decoration:line-through}',
270
+ '.dpc-evidence{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);word-break:break-word}',
271
+ '.dpc-suggest-line{font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary);word-break:break-word}',
272
+ '.dpc-updated{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary)}',
273
+ '.dpc-actions{display:flex;flex-wrap:wrap;gap:8px;padding-top:4px}',
274
+ '.dpc-btn{border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary);border-radius:6px;padding:4px 10px;font-size:12px;line-height:18px;cursor:pointer}',
275
+ '.dpc-btn:hover{background:var(--dsw-alias-bg-layer-1)}',
276
+ '.dpc-btn:disabled{opacity:.45;cursor:default}',
277
+ '.dpc-btn[data-primary="true"]{border-color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
278
+ '.dpc-empty{color:var(--dsw-alias-label-secondary);font-size:13px;line-height:20px}',
279
+ // ── v2 skin: card-style plan panel matching the composer card (radius/shadow/type)
280
+ '.dpc-popover{gap:0;padding:0;border:1px solid var(--dsw-alias-border-l1);border-radius:16px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));box-shadow:0 12px 32px rgba(0,0,0,.14), 0 2px 8px rgba(0,0,0,.06);overflow:hidden;max-height:min(70vh, 560px)}',
281
+ '.dpc-pop-head{flex:none;padding:12px 14px;border-bottom:1px solid var(--dsw-alias-border-l1);gap:8px}',
282
+ '.dpc-pop-title{font-size:14px;font-weight:600;line-height:22px}',
283
+ '.dpc-pop-ver{padding:0 8px;border-radius:999px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:11px;line-height:20px;font-variant-numeric:tabular-nums}',
284
+ '.dpc-pop-chip{padding:0 8px;border-radius:999px;font-size:11px;line-height:20px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));border:0}',
285
+ '.dpc-pop-chip[data-status="completed"]{color:var(--dsw-alias-state-success-primary);background:var(--dsw-alias-state-success-secondary, var(--dsw-alias-bg-layer-2))}',
286
+ '.dpc-pop-chip[data-status="active"]{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary))}',
287
+ '.dpc-pop-close{width:24px;height:24px;line-height:24px;padding:0;border-radius:999px;text-align:center}',
288
+ '.dpc-pop-body{flex:1 1 auto;min-height:0;overflow:auto;display:flex;flex-direction:column;gap:14px;padding:12px 14px}',
289
+ '.dpc-pop-intent{font-size:13px;line-height:21px;color:var(--dsw-alias-label-primary)}',
290
+ '.dpc-label{font-size:11px;line-height:16px;color:var(--dsw-alias-label-tertiary);letter-spacing:.02em}',
291
+ '.dpc-steps{gap:2px}',
292
+ '.dpc-step{padding:6px 8px;border-radius:10px;gap:10px;transition:background .12s ease}',
293
+ '.dpc-step:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
294
+ '.dpc-glyph{width:18px;font-size:12px}',
295
+ '.dpc-step-title{font-size:13px;line-height:20px}',
296
+ '.dpc-evidence{margin-top:1px}',
297
+ '.dpc-reason{font-size:12px;line-height:19px;color:var(--dsw-alias-label-secondary);padding:8px 10px;border-radius:10px;background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));white-space:pre-wrap;word-break:break-word}',
298
+ '.dpc-suggest-card{padding:10px 12px;border-radius:12px;border:1px solid var(--dsw-alias-border-l1);background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1))}',
299
+ '.dpc-suggest-card .dpc-suggest-line{font-size:13px;line-height:20px}',
300
+ '.dpc-pop-foot{flex:none;display:flex;align-items:center;gap:8px;padding:10px 14px;border-top:1px solid var(--dsw-alias-border-l1)}',
301
+ '.dpc-pop-foot .dpc-updated{flex:1 1 auto;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}',
302
+ '.dpc-actions{padding-top:0;gap:8px;flex:none}',
303
+ '.dpc-btn{border-radius:999px;padding:5px 12px;font-size:12px;line-height:18px}',
304
+ '.dpc-empty{padding:14px;font-size:13px}'
305
+ ].join('\n');
306
+
307
+ // Hero fallback entry (rendered from shell.overlay when no session-bound dock exists).
308
+ const HERO_CSS = [
309
+ '.dpc-hero-entry{position:fixed;display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 12px;box-sizing:border-box;border:1px solid var(--dsw-alias-border-l1);border-radius:12px;background:var(--dsw-alias-bg-base, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-secondary);font-size:12px;line-height:18px;box-shadow:0 2px 10px rgba(0,0,0,.08);cursor:pointer;z-index:40}',
310
+ '.dpc-hero-entry:hover{background:var(--dsw-alias-bg-layer-2, var(--dsw-alias-bg-layer-1));color:var(--dsw-alias-label-primary)}',
311
+ '.dpc-hero-entry .dpc-entry-icon{color:var(--dsw-alias-brand-primary, var(--dsw-alias-state-business-primary));font-size:11px}'
312
+ ].join('\n');
313
+
314
+ // =====================================================================
315
+ // Small generic helpers
316
+ // =====================================================================
317
+ function preventDefault(event) {
318
+ if (event && typeof event.preventDefault === 'function') event.preventDefault();
319
+ }
320
+
321
+ function stopPropagation(event) {
322
+ if (event && typeof event.stopPropagation === 'function') event.stopPropagation();
323
+ }
324
+
325
+ function listJoin(items) {
326
+ return items.join(FALLBACK_LANG === 'en' ? ', ' : '、');
327
+ }
328
+
329
+ function truncate(text, limit) {
330
+ if (typeof text !== 'string') return '';
331
+ if (text.length <= limit) return text;
332
+ return text.slice(0, Math.max(0, limit - 1)) + '…';
333
+ }
334
+
335
+ function formatTime(timestamp) {
336
+ try {
337
+ const date = new Date(timestamp);
338
+ if (isNaN(date.getTime())) return '';
339
+ return date.toLocaleTimeString(FALLBACK_LANG === 'en' ? 'en-US' : 'zh-CN', {
340
+ hour: '2-digit',
341
+ minute: '2-digit'
342
+ });
343
+ } catch (error) {
344
+ return '';
345
+ }
346
+ }
347
+
348
+ // =====================================================================
349
+ // Plan state normalization adapters (P0 / cross-half boundary)
350
+ // =====================================================================
351
+ function normalizeEvidence(raw) {
352
+ const out = [];
353
+ const list = Array.isArray(raw) ? raw : [];
354
+ for (let index = 0; index < list.length && out.length < 6; index += 1) {
355
+ const value = list[index];
356
+ if (typeof value === 'string' && value.trim() !== '') out.push(truncate(value.trim(), 160));
357
+ }
358
+ return out;
359
+ }
360
+
361
+ function normalizeSteps(raw) {
362
+ const out = [];
363
+ const list = Array.isArray(raw) ? raw : [];
364
+ for (let index = 0; index < list.length; index += 1) {
365
+ const step = list[index];
366
+ if (!step || typeof step !== 'object') continue;
367
+ const title = typeof step.title === 'string' ? step.title.trim() : '';
368
+ if (title === '') continue;
369
+ out.push({
370
+ id: typeof step.id === 'string' && step.id !== '' ? step.id : 's' + (index + 1),
371
+ title: truncate(title, 300),
372
+ status: STEP_STATUSES.indexOf(step.status) >= 0 ? step.status : 'todo',
373
+ evidence: normalizeEvidence(step.evidence)
374
+ });
375
+ }
376
+ return out;
377
+ }
378
+
379
+ function normalizeSuggestion(raw) {
380
+ if (raw === null || raw === undefined) return null;
381
+ if (typeof raw === 'string') {
382
+ const text = raw.trim();
383
+ return text === '' ? null : { text: truncate(text, 300), alternates: [] };
384
+ }
385
+ if (typeof raw !== 'object') return null;
386
+ const text = typeof raw.text === 'string' ? raw.text.trim() : '';
387
+ if (text === '') return null;
388
+ const alternates = [];
389
+ const pushAlternate = function pushAlternate(value) {
390
+ if (typeof value !== 'string') return;
391
+ const alt = value.trim();
392
+ if (alt === '' || alt === text || alternates.length >= 2) return;
393
+ if (alternates.indexOf(alt) >= 0) return;
394
+ alternates.push(truncate(alt, 300));
395
+ };
396
+ if (Array.isArray(raw.alternates)) {
397
+ for (let index = 0; index < raw.alternates.length; index += 1) pushAlternate(raw.alternates[index]);
398
+ } else {
399
+ pushAlternate(raw.text2);
400
+ pushAlternate(raw.text3);
401
+ }
402
+ return { text: truncate(text, 300), alternates: alternates };
403
+ }
404
+
405
+ function normalizeVersion(raw) {
406
+ if (!raw || typeof raw !== 'object') return null;
407
+ const gen = Number(raw.gen);
408
+ const rev = Number(raw.rev);
409
+ if (!isFinite(gen) || !isFinite(rev)) return null;
410
+ return { gen: gen, rev: rev };
411
+ }
412
+
413
+ /** Tolerate both the Host wire shape and a `{ plan: ... }` envelope. */
414
+ function normalizePlan(raw) {
415
+ if (!raw || typeof raw !== 'object') return null;
416
+ const nested = raw.plan && typeof raw.plan === 'object' ? raw.plan : null;
417
+ const doc = normalizeVersion(raw.version) ? raw : nested;
418
+ if (!doc) return null;
419
+ const version = normalizeVersion(doc.version);
420
+ if (!version) return null;
421
+ // Fields may arrive on the flat object or inside the nested `plan` object.
422
+ const pick = function pick(field) {
423
+ if (doc[field] !== undefined && doc[field] !== null) return doc[field];
424
+ return raw[field];
425
+ };
426
+ const status = pick('status');
427
+ const updatedAt = pick('updatedAt');
428
+ const intent = pick('intent');
429
+ const reason = pick('reason');
430
+ return {
431
+ sessionId: typeof pick('sessionId') === 'string' ? pick('sessionId') : null,
432
+ version: version,
433
+ status: status === 'completed' || status === 'archived' ? status : 'active',
434
+ intent: typeof intent === 'string' ? truncate(intent, 400) : '',
435
+ steps: normalizeSteps(pick('steps')),
436
+ updatedAt: typeof updatedAt === 'number' && isFinite(updatedAt) ? updatedAt : null,
437
+ reason: typeof reason === 'string' ? truncate(reason, 300) : '',
438
+ suggestion: normalizeSuggestion(pick('suggestion')),
439
+ stale: pick('stale') === true
440
+ };
441
+ }
442
+
443
+ function planSignature(plan) {
444
+ if (!plan) return null;
445
+ try {
446
+ return JSON.stringify([
447
+ plan.version.gen,
448
+ plan.version.rev,
449
+ plan.status,
450
+ plan.intent,
451
+ plan.updatedAt,
452
+ plan.reason,
453
+ plan.stale,
454
+ plan.steps.map(function stepKey(step) {
455
+ return [step.id, step.title, step.status, step.evidence];
456
+ }),
457
+ plan.suggestion ? [plan.suggestion.text, plan.suggestion.alternates] : null
458
+ ]);
459
+ } catch (error) {
460
+ return null;
461
+ }
462
+ }
463
+
464
+ function getSuggestion(plan) {
465
+ return plan && plan.suggestion ? plan.suggestion : null;
466
+ }
467
+
468
+ function suggestionKey(plan, suggestion) {
469
+ const version = plan && plan.version ? plan.version.gen + '.' + plan.version.rev : '';
470
+ return version + ':' + (suggestion ? suggestion.text : '');
471
+ }
472
+
473
+ function versionLabel(plan) {
474
+ return plan && plan.version ? 'v' + plan.version.gen + '.' + plan.version.rev : '';
475
+ }
476
+
477
+ function planFillText(plan) {
478
+ if (!plan) return '';
479
+ const suggestion = getSuggestion(plan);
480
+ if (suggestion) return suggestion.text;
481
+ const doing = plan.steps.filter(function isDoing(step) {
482
+ return step.status === 'doing';
483
+ });
484
+ const todo = plan.steps.filter(function isTodo(step) {
485
+ return step.status === 'todo';
486
+ });
487
+ const next = doing[0] || todo[0];
488
+ return next ? next.title : '';
489
+ }
490
+
491
+ /** "Sync to Agent": render the plan as a natural-language instruction (fills draft only). */
492
+ function buildSyncPrompt(plan) {
493
+ if (!plan) return '';
494
+ const t = makeT(null);
495
+ const groups = { done: [], doing: [], todo: [] };
496
+ for (let index = 0; index < plan.steps.length; index += 1) {
497
+ const step = plan.steps[index];
498
+ if (step.status === 'done') groups.done.push(step.title);
499
+ else if (step.status === 'doing') groups.doing.push(step.title);
500
+ else if (step.status === 'todo') groups.todo.push(step.title);
501
+ }
502
+ const parts = [t('sync.prefix')];
503
+ if (plan.intent) parts.push(t('sync.intent', { value: plan.intent }));
504
+ if (groups.done.length > 0) parts.push(t('sync.done', { value: listJoin(groups.done) }));
505
+ if (groups.doing.length > 0) parts.push(t('sync.doing', { value: listJoin(groups.doing) }));
506
+ if (groups.todo.length > 0) parts.push(t('sync.todo', { value: listJoin(groups.todo) }));
507
+ if (plan.reason) parts.push(t('sync.reason', { value: plan.reason }));
508
+ const suggestion = getSuggestion(plan);
509
+ if (suggestion) parts.push(t('sync.next', { value: suggestion.text }));
510
+ if (parts.length === 1) parts.push(t('sync.fallback'));
511
+ return parts.join(' ').replace(/\s+/g, ' ').trim();
512
+ }
513
+
514
+ // =====================================================================
515
+ // Module store shared by dock, overlay and the global keyboard handler
516
+ // =====================================================================
517
+ function createStore(initial) {
518
+ let state = initial;
519
+ const listeners = new Set();
520
+ return {
521
+ get: function get() {
522
+ return state;
523
+ },
524
+ set: function set(patch) {
525
+ let changed = false;
526
+ for (const key in patch) {
527
+ if (state[key] !== patch[key]) {
528
+ changed = true;
529
+ break;
530
+ }
531
+ }
532
+ if (!changed) return;
533
+ state = Object.assign({}, state, patch);
534
+ const snapshot = [];
535
+ listeners.forEach(function collect(listener) {
536
+ snapshot.push(listener);
537
+ });
538
+ for (let index = 0; index < snapshot.length; index += 1) {
539
+ try {
540
+ snapshot[index]();
541
+ } catch (error) {
542
+ /* a broken subscriber must not break the keyboard path */
543
+ }
544
+ }
545
+ },
546
+ subscribe: function subscribe(listener) {
547
+ listeners.add(listener);
548
+ return function unsubscribe() {
549
+ listeners.delete(listener);
550
+ };
551
+ }
552
+ };
553
+ }
554
+
555
+ const store = createStore({
556
+ plan: null,
557
+ planSig: null,
558
+ sessionId: undefined,
559
+ overlayOpen: false,
560
+ anchorRect: null,
561
+ dismissedKey: null,
562
+ suggestionVisible: false,
563
+ inputActions: null,
564
+ draftText: '',
565
+ inputEmpty: true,
566
+ dockOwner: null,
567
+ dockRoot: null,
568
+ triggerEl: null,
569
+ popoverRoot: null
570
+ });
571
+
572
+ function useStoreSelector(selector) {
573
+ const [value, setValue] = React.useState(function initial() {
574
+ return selector(store.get());
575
+ });
576
+ React.useEffect(function subscribeToStore() {
577
+ const sync = function sync() {
578
+ setValue(function compare(previous) {
579
+ const next = selector(store.get());
580
+ return Object.is(previous, next) ? previous : next;
581
+ });
582
+ };
583
+ sync();
584
+ return store.subscribe(sync);
585
+ }, [selector]);
586
+ return value;
587
+ }
588
+
589
+ const selectPlan = function selectPlan(state) {
590
+ return state.plan;
591
+ };
592
+ const selectDismissedKey = function selectDismissedKey(state) {
593
+ return state.dismissedKey;
594
+ };
595
+ const selectOverlayOpen = function selectOverlayOpen(state) {
596
+ return state.overlayOpen;
597
+ };
598
+ const selectAll = function selectAll(state) {
599
+ return state;
600
+ };
601
+
602
+ // =====================================================================
603
+ // Plan state source (P0 boundary #1) — same-origin HTTP route.
604
+ // =====================================================================
605
+ let planRefresher = null;
606
+ let lastPlanSessionId;
607
+ // Timestamp of the last state request, shared by every trigger path; drives the
608
+ // `stale-poll` watchdog. Updated whenever a request is actually issued.
609
+ let lastStateRequestAt = 0;
610
+ // All readers (including warm backfill) share an order and a Dock lifecycle.
611
+ // Versions alone are insufficient: a reset may legitimately start at v0.0,
612
+ // and a model can refine the suggestion without changing the plan version.
613
+ let planRequestSequence = 0;
614
+ let planRequestEpoch = 0;
615
+ let lastAppliedPlanRequest = 0;
616
+
617
+ function beginPlanRequest(sessionId) {
618
+ return { id: ++planRequestSequence, epoch: planRequestEpoch, sessionId: sessionId, owner: store.get().dockOwner };
619
+ }
620
+
621
+ function isCurrentPlanRequest(sessionId, request) {
622
+ const current = store.get();
623
+ return !!request && !!request.owner && request.owner === current.dockOwner &&
624
+ request.epoch === planRequestEpoch && request.sessionId === sessionId &&
625
+ current.sessionId === sessionId && request.id >= lastAppliedPlanRequest;
626
+ }
627
+
628
+ function releaseDock(owner) {
629
+ if (store.get().dockOwner !== owner) return;
630
+ planRequestEpoch += 1;
631
+ lastAppliedPlanRequest = 0;
632
+ planRefresher = null;
633
+ store.set({
634
+ dockOwner: null, sessionId: undefined, plan: null, planSig: null,
635
+ inputActions: null, dockRoot: null, draftText: '', inputEmpty: true,
636
+ suggestionVisible: false, dismissedKey: null, overlayOpen: false,
637
+ anchorRect: null, triggerEl: null, popoverRoot: null
638
+ });
639
+ }
640
+
641
+ /**
642
+ * Resolve `{ ok, plan }`; `ok` false means the route is absent or the payload is unusable.
643
+ * `reason` is diagnostic only (`mount` / `session` / `turn-end` / `poll` /
644
+ * `stale-poll` / `popover`) and `running` mirrors the client-side running
645
+ * detection (`true` / `false` / `undefined`). Both are forwarded as query
646
+ * parameters so the Host log can attribute every request and reveal what
647
+ * `useSession` returned.
648
+ */
649
+ function fetchPlanState(sessionId, reason, running) {
650
+ const requestToken = beginPlanRequest(sessionId);
651
+ if (DEV_PLAN) {
652
+ return Promise.resolve({ ok: true, plan: normalizePlan(DEV_PLAN), request: requestToken });
653
+ }
654
+ if (typeof sessionId !== 'string' || sessionId === '') {
655
+ return Promise.resolve({ ok: false, plan: null });
656
+ }
657
+ let request = null;
658
+ const requestReason = typeof reason === 'string' && reason !== '' ? reason : 'mount';
659
+ const runningText = running === true || running === false ? String(running) : 'undefined';
660
+ try {
661
+ if (typeof fetch !== 'function') return Promise.resolve({ ok: false, plan: null });
662
+ const url =
663
+ STATE_PATH +
664
+ '?sessionId=' +
665
+ encodeURIComponent(sessionId) +
666
+ '&reason=' +
667
+ encodeURIComponent(requestReason) +
668
+ '&running=' +
669
+ encodeURIComponent(runningText);
670
+ lastStateRequestAt = Date.now();
671
+ request = fetch(url, {
672
+ method: 'GET',
673
+ credentials: 'same-origin',
674
+ headers: { accept: 'application/json' }
675
+ });
676
+ } catch (error) {
677
+ return Promise.resolve({ ok: false, plan: null });
678
+ }
679
+ return Promise.resolve(request)
680
+ .then(function readResponse(response) {
681
+ if (!response || response.ok !== true) return { ok: false, plan: null };
682
+ return Promise.resolve(response.json()).then(
683
+ function parsed(json) {
684
+ return { ok: true, plan: normalizePlan(json), request: requestToken,
685
+ debugTheme: json && typeof json === 'object' ? json.debugTheme : null };
686
+ },
687
+ function badJson() {
688
+ return { ok: false, plan: null };
689
+ }
690
+ );
691
+ })
692
+ .catch(function networkError() {
693
+ return { ok: false, plan: null };
694
+ });
695
+ }
696
+
697
+ /**
698
+ * P0-1 warm backfill: ask the Host to build a plan for a session it has never observed
699
+ * (old sessions / plugin just enabled). Resolves with the Host's JSON or null.
700
+ */
701
+ function warmPlanState(sessionId) {
702
+ if (typeof sessionId !== 'string' || sessionId === '') return Promise.resolve(null);
703
+ const requestToken = beginPlanRequest(sessionId);
704
+ let request = null;
705
+ try {
706
+ request = fetch('/plan-completion/warm', {
707
+ method: 'POST',
708
+ credentials: 'same-origin',
709
+ headers: { 'content-type': 'application/json' },
710
+ body: JSON.stringify({ sessionId: sessionId })
711
+ });
712
+ } catch (error) {
713
+ return Promise.resolve(null);
714
+ }
715
+ return Promise.resolve(request)
716
+ .then(function readResponse(response) {
717
+ if (!response || response.ok !== true) return null;
718
+ return Promise.resolve(response.json()).then(
719
+ function parsed(json) {
720
+ return json && typeof json === 'object' ? { ok: json.ok === true, json: json, request: requestToken } : null;
721
+ },
722
+ function badJson() {
723
+ return null;
724
+ }
725
+ );
726
+ })
727
+ .catch(function networkError() {
728
+ return null;
729
+ });
730
+ }
731
+
732
+ /**
733
+ * P0 boundary: read the current plan state. Keep this signature stable —
734
+ * swapping the transport (HTTP route / projection / wire view) only changes
735
+ * this function and fetchPlanState.
736
+ */
737
+ function getPlanState(sessionId, reason, running) {
738
+ return fetchPlanState(sessionId, reason, running).then(function selectPlan(result) {
739
+ return result.plan;
740
+ });
741
+ }
742
+
743
+ function applyPlanResult(sessionId, plan, request) {
744
+ if (!plan || !isCurrentPlanRequest(sessionId, request)) return false;
745
+ if (plan.sessionId && plan.sessionId !== sessionId) return false;
746
+ const current = store.get();
747
+ lastAppliedPlanRequest = request.id;
748
+ const signature = planSignature(plan);
749
+ if (current.plan && current.planSig === signature) return true;
750
+ store.set({ plan: plan, planSig: signature });
751
+ return true;
752
+ }
753
+
754
+ /**
755
+ * Server said "no plan for this session" (unknown / cleared / reset). Drop the cached plan
756
+ * so the bar falls back to its always-on state instead of showing a stale suggestion.
757
+ */
758
+ function clearPlanResult(sessionId, request) {
759
+ if (!isCurrentPlanRequest(sessionId, request)) return false;
760
+ const current = store.get();
761
+ lastAppliedPlanRequest = request.id;
762
+ if (!current.plan) return true;
763
+ store.set({ plan: null, planSig: null, dismissedKey: null, overlayOpen: false });
764
+ return true;
765
+ }
766
+
767
+ function handlePlanFetch(sessionId, result) {
768
+ if (!result || result.ok !== true) return false;
769
+ const applied = result.plan
770
+ ? applyPlanResult(sessionId, result.plan, result.request)
771
+ : clearPlanResult(sessionId, result.request);
772
+ if (applied && result.debugTheme) applyDebugTheme(result.debugTheme);
773
+ return applied;
774
+ }
775
+
776
+ // =====================================================================
777
+ // Composer write adapters (P0 boundary #2)
778
+ // =====================================================================
779
+ /** Tab fill: insert at the caret. */
780
+ function insertIntoComposer(inputActions, text) {
781
+ if (!inputActions || typeof text !== 'string' || text === '') return false;
782
+ try {
783
+ if (typeof inputActions.captureInsertion === 'function' && typeof inputActions.insertText === 'function') {
784
+ const span = inputActions.captureInsertion();
785
+ const result = inputActions.insertText(text, span);
786
+ return result !== false;
787
+ }
788
+ if (typeof inputActions.insertText === 'function') {
789
+ const result = inputActions.insertText(text);
790
+ return result !== false;
791
+ }
792
+ if (typeof inputActions.setDraft === 'function') {
793
+ inputActions.setDraft(text);
794
+ return true;
795
+ }
796
+ } catch (error) {
797
+ return false;
798
+ }
799
+ return false;
800
+ }
801
+
802
+ /** Whole-draft replace: used by popover [Fill draft] / [Sync to Agent]. */
803
+ function replaceComposerDraft(inputActions, text) {
804
+ if (!inputActions || typeof text !== 'string' || text === '') return false;
805
+ try {
806
+ if (typeof inputActions.setDraft === 'function') {
807
+ inputActions.setDraft(text);
808
+ return true;
809
+ }
810
+ return insertIntoComposer(inputActions, text);
811
+ } catch (error) {
812
+ return false;
813
+ }
814
+ }
815
+
816
+ // =====================================================================
817
+ // Focus / menus (P0 boundary #3): conservative keyboard gating
818
+ // =====================================================================
819
+ function isEditableElement(element) {
820
+ if (!element || typeof element !== 'object') return false;
821
+ const tag = typeof element.tagName === 'string' ? element.tagName.toLowerCase() : '';
822
+ if (tag === 'textarea' || tag === 'input') return true;
823
+ if (element.isContentEditable === true) return true;
824
+ try {
825
+ const attr = typeof element.getAttribute === 'function' ? element.getAttribute('contenteditable') : null;
826
+ if (attr === '' || attr === 'true' || attr === 'plaintext-only') return true;
827
+ } catch (error) {
828
+ /* ignore */
829
+ }
830
+ return false;
831
+ }
832
+
833
+ /** Nearest ancestor of our dock that already owns the composer card. */
834
+ function composerRootFromDock(dockEl) {
835
+ if (!dockEl || !dockEl.parentElement) return null;
836
+ let node = dockEl.parentElement;
837
+ for (let depth = 0; depth < 6 && node && node !== document.body; depth += 1) {
838
+ try {
839
+ if (typeof node.querySelector === 'function' && node.querySelector('[data-composer-card]') !== null) return node;
840
+ } catch (error) {
841
+ return null;
842
+ }
843
+ node = node.parentElement;
844
+ }
845
+ return null;
846
+ }
847
+
848
+ /**
849
+ * True only when the focused element is the composer editor:
850
+ * editable AND (inside a composer card OR inside an ancestor of our dock OR
851
+ * carries a composer-ish class). Never true for the suggestion bar itself.
852
+ */
853
+ function isEditorFocused(dockEl) {
854
+ try {
855
+ const active = document.activeElement;
856
+ if (!active || active === document.body) return false;
857
+ if (!isEditableElement(active)) return false;
858
+ if (dockEl && typeof dockEl.contains === 'function' && dockEl.contains(active)) return false;
859
+ // Evidence 1: host marks the composer card.
860
+ if (typeof active.closest === 'function' && active.closest('[data-composer-card]') !== null) return true;
861
+ // Evidence 2: focused editor shares an ancestor with our dock.
862
+ if (dockEl && typeof dockEl.parentElement === 'object' && dockEl.parentElement) {
863
+ let node = dockEl.parentElement;
864
+ for (let depth = 0; depth < 5 && node && node !== document.body; depth += 1) {
865
+ if (typeof node.contains === 'function' && node.contains(active)) return true;
866
+ node = node.parentElement;
867
+ }
868
+ }
869
+ // Evidence 3: class-name fallback.
870
+ let node = active;
871
+ for (let depth = 0; depth < 8 && node && node !== document.body; depth += 1) {
872
+ const className = typeof node.className === 'string' ? node.className : '';
873
+ if (/composer/i.test(className)) return true;
874
+ node = node.parentElement;
875
+ }
876
+ return false;
877
+ } catch (error) {
878
+ return false;
879
+ }
880
+ }
881
+
882
+ /**
883
+ * Conservative "a slash/@/command menu is up" check. Empty drafts cannot
884
+ * open slash/@ menus, so this guards the programmatic "+" command menu.
885
+ */
886
+ function hasOpenComposerMenu(dockEl) {
887
+ try {
888
+ const root = composerRootFromDock(dockEl);
889
+ if (!root || typeof root.querySelectorAll !== 'function') return false;
890
+ const nodes = root.querySelectorAll(
891
+ '[data-composer-overlay] [role="listbox"], [data-composer-overlay] [role="menu"], [data-composer-overlay] [role="dialog"]'
892
+ );
893
+ for (let index = 0; index < nodes.length; index += 1) {
894
+ const node = nodes[index];
895
+ if (node.getClientRects && node.getClientRects().length > 0) return true;
896
+ }
897
+ return false;
898
+ } catch (error) {
899
+ return false;
900
+ }
901
+ }
902
+
903
+ function hasTriggerText(text) {
904
+ const value = typeof text === 'string' ? text.replace(/^\s+/, '') : '';
905
+ return value.startsWith('/') || value.startsWith('@');
906
+ }
907
+
908
+ function isAnyModalOpen() {
909
+ try {
910
+ if (typeof document === 'undefined' || typeof document.querySelectorAll !== 'function') return false;
911
+ const nodes = document.querySelectorAll('[aria-modal="true"], dialog[open]');
912
+ for (let index = 0; index < nodes.length; index += 1) {
913
+ const node = nodes[index];
914
+ if (node.getClientRects && node.getClientRects().length > 0) return true;
915
+ }
916
+ return false;
917
+ } catch (error) {
918
+ return false;
919
+ }
920
+ }
921
+
922
+ // =====================================================================
923
+ // Overlay / suggestion control
924
+ // =====================================================================
925
+ function openOverlay(triggerEl) {
926
+ const current = store.get();
927
+ const anchorEl = triggerEl || current.triggerEl || current.dockRoot;
928
+ let rect = null;
929
+ try {
930
+ if (anchorEl && typeof anchorEl.getBoundingClientRect === 'function') {
931
+ const box = anchorEl.getBoundingClientRect();
932
+ rect = { top: box.top, right: box.right, bottom: box.bottom, left: box.left, width: box.width, height: box.height };
933
+ }
934
+ } catch (error) {
935
+ rect = null;
936
+ }
937
+ store.set({ overlayOpen: true, triggerEl: anchorEl || null, anchorRect: rect });
938
+ }
939
+
940
+ function closeOverlay() {
941
+ store.set({ overlayOpen: false });
942
+ }
943
+
944
+ function toggleOverlay(triggerEl) {
945
+ if (store.get().overlayOpen) {
946
+ closeOverlay();
947
+ return;
948
+ }
949
+ openOverlay(triggerEl);
950
+ }
951
+
952
+ function dismissSuggestion() {
953
+ const current = store.get();
954
+ const suggestion = getSuggestion(current.plan);
955
+ if (!suggestion) return;
956
+ store.set({ dismissedKey: suggestionKey(current.plan, suggestion) });
957
+ }
958
+
959
+ function suggestionTexts(plan) {
960
+ const suggestion = getSuggestion(plan);
961
+ if (!suggestion) return [];
962
+ return [suggestion.text].concat(suggestion.alternates || []);
963
+ }
964
+
965
+ // =====================================================================
966
+ // Global keyboard handler (window capture, installed through ctx.effect)
967
+ // =====================================================================
968
+ function canTabFill(state) {
969
+ if (!state.dockOwner || !state.dockRoot || !state.inputActions) return false;
970
+ if (state.dockRoot.isConnected === false) return false;
971
+ if (!state.suggestionVisible) return false;
972
+ const suggestion = getSuggestion(state.plan);
973
+ if (!suggestion) return false;
974
+ if (state.dismissedKey === suggestionKey(state.plan, suggestion)) return false;
975
+ if (!state.inputEmpty) return false;
976
+ if (typeof state.draftText === 'string' && state.draftText.trim() !== '') return false;
977
+ if (hasTriggerText(state.draftText)) return false;
978
+ if (!isEditorFocused(state.dockRoot)) return false;
979
+ if (hasOpenComposerMenu(state.dockRoot)) return false;
980
+ return true;
981
+ }
982
+
983
+ function handleKeyDown(event) {
984
+ const e = event;
985
+ if (!e || e.defaultPrevented) return;
986
+ if (e.isComposing || e.keyCode === 229) return;
987
+ const key = typeof e.key === 'string' ? e.key : '';
988
+ const state = store.get();
989
+
990
+ // Ctrl+Alt+P — toggle the plan popover (fixed global shortcut).
991
+ if (e.ctrlKey && e.altKey && !e.shiftKey && !e.metaKey && (key === 'p' || key === 'P')) {
992
+ if (isAnyModalOpen()) return;
993
+ preventDefault(e);
994
+ stopPropagation(e);
995
+ toggleOverlay(null);
996
+ return;
997
+ }
998
+
999
+ // Tab — fill the current suggestion.
1000
+ if (key === 'Tab' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && !e.isComposing) {
1001
+ if (!canTabFill(state)) return;
1002
+ const suggestion = getSuggestion(state.plan);
1003
+ if (!suggestion) return;
1004
+ preventDefault(e);
1005
+ stopPropagation(e);
1006
+ insertIntoComposer(state.inputActions, suggestion.text);
1007
+ return;
1008
+ }
1009
+
1010
+ // Esc — close the popover, else dismiss the visible suggestion.
1011
+ if (key === 'Escape' && !e.ctrlKey && !e.altKey && !e.metaKey) {
1012
+ if (state.overlayOpen) {
1013
+ if (isAnyModalOpen()) return;
1014
+ preventDefault(e);
1015
+ stopPropagation(e);
1016
+ closeOverlay();
1017
+ return;
1018
+ }
1019
+ if (state.suggestionVisible && isEditorFocused(state.dockRoot)) {
1020
+ preventDefault(e);
1021
+ stopPropagation(e);
1022
+ dismissSuggestion();
1023
+ return;
1024
+ }
1025
+ return;
1026
+ }
1027
+
1028
+ // Alt+1..3 — pick the primary suggestion or one of its alternates.
1029
+ if (e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey && /^[1-3]$/.test(key)) {
1030
+ if (!state.suggestionVisible) return;
1031
+ if (state.dismissedKey === suggestionKey(state.plan, getSuggestion(state.plan))) return;
1032
+ const texts = suggestionTexts(state.plan);
1033
+ const text = texts[Number(key) - 1];
1034
+ if (!text) return;
1035
+ if (!isEditorFocused(state.dockRoot) && !state.overlayOpen) return;
1036
+ preventDefault(e);
1037
+ stopPropagation(e);
1038
+ insertIntoComposer(state.inputActions, text);
1039
+ }
1040
+ }
1041
+
1042
+ function handlePointerDown(event) {
1043
+ const state = store.get();
1044
+ if (!state.overlayOpen) return;
1045
+ const target = event && event.target;
1046
+ if (!target || typeof target.nodeType !== 'number') return;
1047
+ try {
1048
+ if (state.popoverRoot && state.popoverRoot.contains(target)) return;
1049
+ if (state.triggerEl && state.triggerEl.contains(target)) return;
1050
+ if (state.dockRoot && state.dockRoot.contains(target)) return;
1051
+ } catch (error) {
1052
+ return;
1053
+ }
1054
+ closeOverlay();
1055
+ }
1056
+
1057
+ // =====================================================================
1058
+ // Slot components
1059
+ // =====================================================================
1060
+ // InputState / Session snapshots use selector hooks: always pass a function.
1061
+ const selectInputDraft = function selectInputDraft(state) {
1062
+ return state ? state.draft : undefined;
1063
+ };
1064
+ const selectInputPhase = function selectInputPhase(state) {
1065
+ return state ? state.phase : undefined;
1066
+ };
1067
+
1068
+ function useInputSelector(props, selector) {
1069
+ try {
1070
+ if (props && typeof props.useInput === 'function') return props.useInput(selector);
1071
+ } catch (error) {
1072
+ return undefined;
1073
+ }
1074
+ return undefined;
1075
+ }
1076
+
1077
+ function readRunningValue(value) {
1078
+ if (value === true || value === false) return value;
1079
+ if (!value || typeof value !== 'object') return null;
1080
+ if (typeof value.running === 'boolean') return value.running;
1081
+ if (typeof value.status === 'string') {
1082
+ if (value.status === 'running') return true;
1083
+ if (value.status === 'idle') return false;
1084
+ }
1085
+ if (typeof value.phase === 'string') {
1086
+ if (value.phase === 'running') return true;
1087
+ if (value.phase === 'idle' || value.phase === 'done') return false;
1088
+ }
1089
+ return null;
1090
+ }
1091
+
1092
+ /**
1093
+ * running: true/false when the host exposes it, null when unknown.
1094
+ * Both hooks are invoked unconditionally so hook order stays stable.
1095
+ */
1096
+ function useSessionRunning(props) {
1097
+ let direct = undefined;
1098
+ try {
1099
+ if (props && typeof props.useSession === 'function') {
1100
+ direct = props.useSession(function selectRunning(session) {
1101
+ return session && typeof session === 'object' ? session.running === true : undefined;
1102
+ });
1103
+ }
1104
+ } catch (error) {
1105
+ direct = undefined;
1106
+ }
1107
+ let mapped = undefined;
1108
+ try {
1109
+ if (props && typeof props.useSessionStatus === 'function') {
1110
+ const sessionId = props.sessionId;
1111
+ mapped = props.useSessionStatus(function selectStatus(snapshot) {
1112
+ if (!snapshot) return undefined;
1113
+ if (typeof snapshot.get === 'function') return snapshot.get(sessionId);
1114
+ if (sessionId && snapshot[sessionId]) return snapshot[sessionId];
1115
+ return undefined;
1116
+ });
1117
+ }
1118
+ } catch (error) {
1119
+ mapped = undefined;
1120
+ }
1121
+ const running = readRunningValue(direct);
1122
+ if (running !== null) return running;
1123
+ return readRunningValue(mapped);
1124
+ }
1125
+
1126
+ function Dock(props) {
1127
+ const plan = useStoreSelector(selectPlan);
1128
+ const dismissedKey = useStoreSelector(selectDismissedKey);
1129
+ const overlayOpen = useStoreSelector(selectOverlayOpen);
1130
+ const t = makeT(props);
1131
+
1132
+ // Hook calls stay unconditional; values may be undefined before the input face mounts.
1133
+ const draftValue = useInputSelector(props, selectInputDraft);
1134
+ const phaseValue = useInputSelector(props, selectInputPhase);
1135
+ const runningValue = useSessionRunning(props);
1136
+ // `undefined` means unknown: the host exposes no usable running signal, so the
1137
+ // polling fallback below takes over instead of relying on turn-end transitions.
1138
+ const running = runningValue === true || runningValue === false ? runningValue : undefined;
1139
+
1140
+ const sessionId = props && typeof props.sessionId === 'string' ? props.sessionId : undefined;
1141
+ const inputActions = props && props.inputActions ? props.inputActions : null;
1142
+ const draft = typeof draftValue === 'string' ? draftValue : null;
1143
+ const phaseOk = phaseValue === undefined || phaseValue === 'plain';
1144
+ const inputEmpty = draft === '' && phaseOk;
1145
+
1146
+ const [turnTick, setTurnTick] = React.useState(0);
1147
+ const previousRunningRef = React.useRef(running);
1148
+ const turnEndedRef = React.useRef(false);
1149
+ const runningRef = React.useRef(running);
1150
+ runningRef.current = running;
1151
+ const rootRef = React.useRef(null);
1152
+ const ownerRef = React.useRef({});
1153
+ const suggestion = getSuggestion(plan);
1154
+ const currentSuggestionKey = suggestionKey(plan, suggestion);
1155
+ const showSuggestion = !!suggestion && inputEmpty && dismissedKey !== currentSuggestionKey;
1156
+
1157
+ // The overlay and keyboard handler outlive a conversation Dock. Its cleanup
1158
+ // must not expose a previous session, or erase a newer Dock's ownership.
1159
+ React.useEffect(function ownRuntimeFacts() {
1160
+ planRequestEpoch += 1;
1161
+ lastAppliedPlanRequest = 0;
1162
+ store.set({ dockOwner: ownerRef.current });
1163
+ const owner = ownerRef.current;
1164
+ return function cleanupRuntimeFacts() { releaseDock(owner); };
1165
+ }, [sessionId]);
1166
+
1167
+ // Publish the facts the out-of-React keyboard handler needs.
1168
+ React.useEffect(function publishRuntimeFacts() {
1169
+ const current = store.get();
1170
+ if (current.dockOwner !== ownerRef.current) return;
1171
+ const patch = {};
1172
+ if (current.sessionId !== sessionId) {
1173
+ patch.sessionId = sessionId;
1174
+ patch.plan = null;
1175
+ patch.planSig = null;
1176
+ patch.dismissedKey = null;
1177
+ patch.overlayOpen = false;
1178
+ patch.anchorRect = null;
1179
+ patch.triggerEl = null;
1180
+ }
1181
+ if (current.inputActions !== inputActions) patch.inputActions = inputActions;
1182
+ const draftText = draft === null ? '' : draft;
1183
+ if (current.draftText !== draftText) patch.draftText = draftText;
1184
+ if (current.inputEmpty !== inputEmpty) patch.inputEmpty = inputEmpty;
1185
+ if (current.suggestionVisible !== showSuggestion) patch.suggestionVisible = showSuggestion;
1186
+ if (current.dockRoot !== rootRef.current) patch.dockRoot = rootRef.current;
1187
+ if (Object.keys(patch).length > 0) store.set(patch);
1188
+ });
1189
+
1190
+ // TEMP verification: auto-open the plan panel once after mount (static screenshot).
1191
+ React.useEffect(function devOpenPopover() {
1192
+ if (!DEV_OPEN_POPOVER || typeof window === 'undefined') return undefined;
1193
+ const timer = window.setTimeout(function openIt() {
1194
+ try {
1195
+ const el = document.querySelector('[data-plan-completion-entry]');
1196
+ if (el) openOverlay(el);
1197
+ } catch (error) {
1198
+ /* ignore */
1199
+ }
1200
+ }, 2500);
1201
+ return function cleanup() {
1202
+ window.clearTimeout(timer);
1203
+ };
1204
+ }, []);
1205
+
1206
+ // Align the floating pill group with the composer card it floats above, so the
1207
+ // suggestion entry shares the composer's left edge instead of the column edge.
1208
+ React.useEffect(function alignWithComposer() {
1209
+ if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
1210
+ let frame = 0;
1211
+ const measure = function measure() {
1212
+ try {
1213
+ const row = rootRef.current;
1214
+ if (!row || typeof row.getBoundingClientRect !== 'function') return;
1215
+ const rowRect = row.getBoundingClientRect();
1216
+ if (rowRect.width <= 0) return;
1217
+ const candidates = Array.from(document.querySelectorAll('[data-composer-card]'));
1218
+ let card = null;
1219
+ let bestDistance = Number.POSITIVE_INFINITY;
1220
+ for (const node of candidates) {
1221
+ const rect = node.getBoundingClientRect();
1222
+ const gap = rect.top - rowRect.bottom;
1223
+ const visible = rect.width > 100 && rect.height > 20;
1224
+ // Prefer the composer card that sits right below this row (the mounted one).
1225
+ if (!visible || gap < -24 || gap > 220) continue;
1226
+ if (gap < bestDistance) {
1227
+ bestDistance = gap;
1228
+ card = rect;
1229
+ }
1230
+ }
1231
+ if (!card) return;
1232
+ const left = Math.max(0, Math.round(card.left - rowRect.left));
1233
+ row.style.paddingLeft = left + 'px';
1234
+ } catch (error) {
1235
+ /* ignore */
1236
+ }
1237
+ };
1238
+ const schedule = function schedule() {
1239
+ if (frame) return;
1240
+ try {
1241
+ frame = window.requestAnimationFrame(function run() {
1242
+ frame = 0;
1243
+ measure();
1244
+ });
1245
+ } catch (error) {
1246
+ frame = 0;
1247
+ measure();
1248
+ }
1249
+ };
1250
+ measure();
1251
+ const timer = window.setTimeout(measure, 400);
1252
+ const timer2 = window.setTimeout(measure, 1200);
1253
+ const timer3 = window.setTimeout(measure, 2500);
1254
+ window.addEventListener('resize', schedule, true);
1255
+ let observer = null;
1256
+ let cardObserver = null;
1257
+ try {
1258
+ if (typeof ResizeObserver === 'function') {
1259
+ observer = new ResizeObserver(schedule);
1260
+ observer.observe(document.documentElement);
1261
+ // The composer card moves when the column width or the tool row changes.
1262
+ const cardEl = document.querySelector('[data-composer-card]');
1263
+ if (cardEl) {
1264
+ cardObserver = new ResizeObserver(schedule);
1265
+ cardObserver.observe(cardEl);
1266
+ }
1267
+ }
1268
+ } catch (error) {
1269
+ /* ignore */
1270
+ }
1271
+ return function cleanupAlign() {
1272
+ try {
1273
+ window.clearTimeout(timer);
1274
+ window.clearTimeout(timer2);
1275
+ window.clearTimeout(timer3);
1276
+ window.removeEventListener('resize', schedule, true);
1277
+ if (observer) observer.disconnect();
1278
+ if (cardObserver) cardObserver.disconnect();
1279
+ if (frame) window.cancelAnimationFrame(frame);
1280
+ } catch (error) {
1281
+ /* ignore */
1282
+ }
1283
+ };
1284
+ }, [sessionId]);
1285
+
1286
+ // Known running transitions bump turnTick; true -> false marks a turn end.
1287
+ // While `running` is unknown no tick can ever fire, and the polling effect
1288
+ // below keeps the plan state fresh instead.
1289
+ React.useEffect(function trackTurnEnd() {
1290
+ const previous = previousRunningRef.current;
1291
+ previousRunningRef.current = running;
1292
+ if (previous === undefined || running === undefined || previous === running) return;
1293
+ turnEndedRef.current = previous === true && running === false;
1294
+ setTurnTick(function bump(value) {
1295
+ return value + 1;
1296
+ });
1297
+ }, [running]);
1298
+
1299
+ // Mount / session switch: read once (silent when the route is absent).
1300
+ React.useEffect(function loadPlanOnMount() {
1301
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1302
+ // Distinguish the first mount from an actual sessionId change for the Host log.
1303
+ const reason = lastPlanSessionId !== undefined && lastPlanSessionId !== sessionId ? 'session' : 'mount';
1304
+ lastPlanSessionId = sessionId;
1305
+ let cancelled = false;
1306
+ const request = function request(kind, allowWarm) {
1307
+ if (cancelled) return;
1308
+ fetchPlanState(sessionId, kind, runningRef.current).then(function loaded(result) {
1309
+ if (cancelled) return;
1310
+ if (!handlePlanFetch(sessionId, result)) return;
1311
+ if (result.plan) return;
1312
+ // P0-1: session known to the app but never observed by this plugin (old session,
1313
+ // plugin just enabled) — ask the Host to backfill from the durable session log.
1314
+ if (allowWarm === false) return;
1315
+ warmPlanState(sessionId).then(function applyWarm(warmed) {
1316
+ if (cancelled || !warmed || !warmed.ok || !warmed.json) return;
1317
+ const warmedPlan = normalizePlan(warmed.json);
1318
+ if (warmedPlan) applyPlanResult(sessionId, warmedPlan, warmed.request);
1319
+ });
1320
+ });
1321
+ };
1322
+ const refresher = function refresh(kind) {
1323
+ request(typeof kind === 'string' && kind !== '' ? kind : 'popover', false);
1324
+ };
1325
+ planRefresher = refresher;
1326
+ request(reason, true);
1327
+ return function cleanup() {
1328
+ cancelled = true;
1329
+ if (planRefresher === refresher) planRefresher = null;
1330
+ };
1331
+ }, [sessionId]);
1332
+
1333
+ // Turn end: silent retries across the full chain (300..9000ms) so both the
1334
+ // fast heuristic suggestion and the later model-refined one are picked up.
1335
+ React.useEffect(function refreshAfterTurnEnd() {
1336
+ if (turnTick === 0) return undefined;
1337
+ if (!turnEndedRef.current) return undefined;
1338
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1339
+ let cancelled = false;
1340
+ let timer = 0;
1341
+ let attempt = 0;
1342
+ function scheduleNext() {
1343
+ if (cancelled) return;
1344
+ attempt += 1;
1345
+ if (attempt >= TURN_RETRY_DELAYS.length) return;
1346
+ timer = window.setTimeout(step, TURN_RETRY_DELAYS[attempt]);
1347
+ }
1348
+ function step() {
1349
+ if (cancelled) return;
1350
+ fetchPlanState(sessionId, 'turn-end', runningRef.current).then(
1351
+ function loaded(result) {
1352
+ if (cancelled) return;
1353
+ handlePlanFetch(sessionId, result);
1354
+ scheduleNext();
1355
+ },
1356
+ function failed() {
1357
+ if (cancelled) return;
1358
+ scheduleNext();
1359
+ }
1360
+ );
1361
+ }
1362
+ timer = window.setTimeout(step, TURN_RETRY_DELAYS[0]);
1363
+ return function cleanup() {
1364
+ cancelled = true;
1365
+ if (timer) window.clearTimeout(timer);
1366
+ };
1367
+ }, [turnTick, sessionId]);
1368
+
1369
+ // Fallback for an unknown `running`: poll every 4s while the page is visible
1370
+ // (`reason=poll`) so a suggestion still appears within ~4-5s of a turn end.
1371
+ React.useEffect(function pollWhileRunningUnknown() {
1372
+ if (running !== undefined) return undefined;
1373
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1374
+ let cancelled = false;
1375
+ let timer = 0;
1376
+ let inFlight = false;
1377
+ function schedule() {
1378
+ if (cancelled) return;
1379
+ if (timer) window.clearTimeout(timer);
1380
+ timer = window.setTimeout(request, POLL_MS);
1381
+ }
1382
+ function request() {
1383
+ if (cancelled || inFlight) return;
1384
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
1385
+ schedule();
1386
+ return;
1387
+ }
1388
+ inFlight = true;
1389
+ fetchPlanState(sessionId, 'poll', runningRef.current).then(
1390
+ function loaded(result) {
1391
+ inFlight = false;
1392
+ if (cancelled) return;
1393
+ handlePlanFetch(sessionId, result);
1394
+ schedule();
1395
+ },
1396
+ function failed() {
1397
+ inFlight = false;
1398
+ if (!cancelled) schedule();
1399
+ }
1400
+ );
1401
+ }
1402
+ schedule();
1403
+ return function cleanup() {
1404
+ cancelled = true;
1405
+ if (timer) window.clearTimeout(timer);
1406
+ };
1407
+ }, [running, sessionId]);
1408
+
1409
+ // Watchdog for a known-but-static `running`: if no state request happened for
1410
+ // 15s, issue one `reason=stale-poll` so suggestions still converge even when
1411
+ // no turn-end transition is ever observed.
1412
+ React.useEffect(function stalePollWatchdog() {
1413
+ if (running === undefined) return undefined;
1414
+ if (typeof sessionId !== 'string' || sessionId === '') return undefined;
1415
+ // A settled (idle) session must converge fast (~4s); during a running turn
1416
+ // 15s is enough because the answer is not ready yet.
1417
+ const interval = running === false ? POLL_MS : STALE_POLL_MS;
1418
+ let cancelled = false;
1419
+ let timer = 0;
1420
+ function schedule() {
1421
+ if (cancelled) return;
1422
+ if (timer) window.clearTimeout(timer);
1423
+ timer = window.setTimeout(check, interval);
1424
+ }
1425
+ function check() {
1426
+ if (cancelled) return;
1427
+ if (typeof document !== 'undefined' && document.visibilityState === 'hidden') {
1428
+ schedule();
1429
+ return;
1430
+ }
1431
+ if (Date.now() - lastStateRequestAt < interval) {
1432
+ schedule();
1433
+ return;
1434
+ }
1435
+ fetchPlanState(sessionId, 'stale-poll', runningRef.current).then(
1436
+ function loaded(result) {
1437
+ if (cancelled) return;
1438
+ handlePlanFetch(sessionId, result);
1439
+ schedule();
1440
+ },
1441
+ function failed() {
1442
+ if (!cancelled) schedule();
1443
+ }
1444
+ );
1445
+ }
1446
+ schedule();
1447
+ return function cleanup() {
1448
+ cancelled = true;
1449
+ if (timer) window.clearTimeout(timer);
1450
+ };
1451
+ }, [running, sessionId]);
1452
+
1453
+ // Opening the popover refreshes state silently.
1454
+ React.useEffect(function refreshOnOpen() {
1455
+ if (!overlayOpen) return;
1456
+ if (typeof planRefresher === 'function') planRefresher('popover');
1457
+ }, [overlayOpen]);
1458
+
1459
+ // The persistent entry is always rendered; with no plan it shows the bare glyph.
1460
+ const label = plan ? versionLabel(plan) : '';
1461
+ const children = [
1462
+ h('style', { key: 'style', 'data-plan-completion-style': 'dock' }, DOCK_CSS)
1463
+ ];
1464
+ const dockChildren = [];
1465
+
1466
+ // Floating suggestion pill on the left; the fixed-height row keeps its space
1467
+ // when hidden so the composer never jumps. The plan pill on the right is a
1468
+ // separate flex child and is always rendered, whatever the suggestion length.
1469
+ const suggestionVisible = showSuggestion && !!suggestion;
1470
+ if (suggestionVisible && suggestion) {
1471
+ const alternatives = suggestion.alternates ? suggestion.alternates.length : 0;
1472
+ const pillChildren = [
1473
+ h('span', { key: 'icon', className: 'dpc-suggest-icon', 'aria-hidden': true }, '⏎'),
1474
+ h('span', { key: 'hint', className: 'dpc-suggest-hint' }, t('suggest.hint')),
1475
+ h('span', { key: 'sep', className: 'dpc-suggest-sep', 'aria-hidden': true }, '·'),
1476
+ h(
1477
+ 'span',
1478
+ { key: 'text', className: 'dpc-suggest-text', title: suggestion.text },
1479
+ '「' + suggestion.text + '」'
1480
+ )
1481
+ ];
1482
+ if (alternatives > 0) {
1483
+ pillChildren.push(
1484
+ h('span', { key: 'alts', className: 'dpc-suggest-alts' }, t('suggest.alts', { count: alternatives + 1 }))
1485
+ );
1486
+ }
1487
+ pillChildren.push(
1488
+ h(
1489
+ 'button',
1490
+ {
1491
+ key: 'dismiss',
1492
+ type: 'button',
1493
+ className: 'dpc-dismiss',
1494
+ tabIndex: -1,
1495
+ 'aria-label': t('suggest.dismiss'),
1496
+ onMouseDown: preventDefault,
1497
+ onClick: function onClickDismiss(event) {
1498
+ stopPropagation(event);
1499
+ dismissSuggestion();
1500
+ }
1501
+ },
1502
+ '✕'
1503
+ )
1504
+ );
1505
+ dockChildren.push(
1506
+ h(
1507
+ 'div',
1508
+ {
1509
+ key: 'suggest',
1510
+ className: 'dpc-pill dpc-suggest-pill',
1511
+ 'data-plan-completion-suggest': 'visible',
1512
+ role: 'status',
1513
+ 'aria-live': 'polite'
1514
+ },
1515
+ pillChildren
1516
+ )
1517
+ );
1518
+ } else {
1519
+ // No suggestion: show a dashed "hint" pill while the session has no plan yet so the
1520
+ // bar stays visible (常驻) even in a brand-new / cleared conversation.
1521
+ if (!plan) {
1522
+ dockChildren.push(
1523
+ h(
1524
+ 'div',
1525
+ { key: 'hint', className: 'dpc-pill dpc-hint-pill', role: 'status' },
1526
+ h('span', { key: 'icon', className: 'dpc-suggest-icon', 'aria-hidden': true }, '▤'),
1527
+ h('span', { key: 'text', className: 'dpc-suggest-text' }, t('suggest.noplan'))
1528
+ )
1529
+ );
1530
+ } else {
1531
+ // Flex spacer: keeps the plan pill pinned left without moving the row.
1532
+ dockChildren.push(
1533
+ h('span', {
1534
+ key: 'suggest',
1535
+ className: 'dpc-suggest-placeholder',
1536
+ 'data-plan-completion-suggest': 'hidden',
1537
+ 'aria-hidden': 'true'
1538
+ })
1539
+ );
1540
+ }
1541
+ }
1542
+
1543
+ dockChildren.push(
1544
+ h(
1545
+ 'button',
1546
+ {
1547
+ key: 'entry',
1548
+ type: 'button',
1549
+ className: 'dpc-pill dpc-entry' + (suggestionVisible ? ' dpc-entry-strong' : ''),
1550
+ 'data-plan-completion-entry': 'true',
1551
+ 'aria-haspopup': 'dialog',
1552
+ 'aria-expanded': overlayOpen ? 'true' : 'false',
1553
+ 'aria-label': t('entry.aria'),
1554
+ title: label ? label + (plan && plan.intent ? ' · ' + plan.intent : '') : t('entry.title'),
1555
+ onMouseDown: preventDefault,
1556
+ onClick: function onClickEntry(event) {
1557
+ stopPropagation(event);
1558
+ toggleOverlay(event && event.currentTarget ? event.currentTarget : null);
1559
+ }
1560
+ },
1561
+ h('span', { key: 'icon', className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1562
+ label
1563
+ ? h('span', { key: 'version', className: 'dpc-entry-version' }, label)
1564
+ : h('span', { key: 'label', className: 'dpc-entry-label' }, t('entry.label'))
1565
+ )
1566
+ );
1567
+
1568
+ children.push(
1569
+ h(
1570
+ 'div',
1571
+ {
1572
+ key: 'root',
1573
+ ref: rootRef,
1574
+ className: 'dpc-root',
1575
+ 'data-plan-completion': 'dock',
1576
+ 'data-plan-completion-version': label
1577
+ },
1578
+ dockChildren
1579
+ )
1580
+ );
1581
+
1582
+ return h(React.Fragment, null, children);
1583
+ }
1584
+
1585
+ function computePopoverPosition(rect) {
1586
+ const viewportWidth = (typeof window !== 'undefined' && window.innerWidth) || 1024;
1587
+ const viewportHeight = (typeof window !== 'undefined' && window.innerHeight) || 768;
1588
+ const width = Math.max(300, Math.min(440, viewportWidth - 24));
1589
+ const style = {
1590
+ position: 'fixed',
1591
+ right: 12,
1592
+ bottom: 12,
1593
+ width: width,
1594
+ maxHeight: Math.max(180, Math.min(520, viewportHeight - 24)),
1595
+ overflow: 'auto'
1596
+ };
1597
+ if (rect && typeof rect.top === 'number' && typeof rect.right === 'number') {
1598
+ style.bottom = Math.max(12, viewportHeight - rect.top + 8);
1599
+ style.right = Math.max(12, Math.min(viewportWidth - rect.right, viewportWidth - width - 12));
1600
+ style.maxHeight = Math.max(160, Math.min(520, rect.top - 16));
1601
+ }
1602
+ return style;
1603
+ }
1604
+
1605
+ function renderCloseButton(t) {
1606
+ return h(
1607
+ 'button',
1608
+ {
1609
+ key: 'close',
1610
+ type: 'button',
1611
+ className: 'dpc-pop-close',
1612
+ 'aria-label': t('popover.close'),
1613
+ onMouseDown: preventDefault,
1614
+ onClick: function onClickClose(event) {
1615
+ stopPropagation(event);
1616
+ closeOverlay();
1617
+ }
1618
+ },
1619
+ '✕'
1620
+ );
1621
+ }
1622
+
1623
+ function renderPopoverContent(plan, inputActions, t) {
1624
+ const children = [];
1625
+ if (!plan) {
1626
+ children.push(
1627
+ h(
1628
+ 'div',
1629
+ { key: 'head', className: 'dpc-pop-head' },
1630
+ h('span', { className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1631
+ h('span', { className: 'dpc-pop-title' }, t('popover.title')),
1632
+ renderCloseButton(t)
1633
+ )
1634
+ );
1635
+ children.push(h('div', { key: 'empty', className: 'dpc-empty' }, t('popover.empty')));
1636
+ return [children[0], h('div', { key: 'body', className: 'dpc-pop-body' }, children.slice(1))];
1637
+ }
1638
+
1639
+ children.push(
1640
+ h(
1641
+ 'div',
1642
+ { key: 'head', className: 'dpc-pop-head' },
1643
+ h('span', { className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1644
+ h('span', { className: 'dpc-pop-title' }, t('popover.title')),
1645
+ h('span', { className: 'dpc-pop-ver' }, versionLabel(plan)),
1646
+ h('span', { className: 'dpc-pop-chip', 'data-status': plan.status }, t('status.' + plan.status)),
1647
+ renderCloseButton(t)
1648
+ )
1649
+ );
1650
+
1651
+ if (plan.stale) {
1652
+ children.push(h('div', { key: 'stale', className: 'dpc-stale', role: 'status' }, t('popover.stale')));
1653
+ }
1654
+
1655
+ if (plan.intent) {
1656
+ children.push(
1657
+ h(
1658
+ 'div',
1659
+ { key: 'intent', className: 'dpc-section' },
1660
+ h('div', { className: 'dpc-label' }, t('popover.intent')),
1661
+ h('div', { className: 'dpc-text' }, plan.intent)
1662
+ )
1663
+ );
1664
+ }
1665
+
1666
+ if (plan.steps.length > 0) {
1667
+ children.push(
1668
+ h(
1669
+ 'div',
1670
+ { key: 'steps', className: 'dpc-section' },
1671
+ h('div', { className: 'dpc-label' }, t('popover.steps')),
1672
+ h(
1673
+ 'ol',
1674
+ { className: 'dpc-steps' },
1675
+ plan.steps.map(function renderStep(step) {
1676
+ const glyph =
1677
+ step.status === 'done' ? '✓' : step.status === 'doing' ? '●' : step.status === 'dropped' ? '✕' : '○';
1678
+ const mainChildren = [h('span', { key: 'title', className: 'dpc-step-title' }, step.title)];
1679
+ if (step.evidence.length > 0) {
1680
+ mainChildren.push(
1681
+ h('span', { key: 'evidence', className: 'dpc-evidence' }, step.evidence.join(' · '))
1682
+ );
1683
+ }
1684
+ return h(
1685
+ 'li',
1686
+ { key: step.id + ':' + step.title, className: 'dpc-step', 'data-status': step.status },
1687
+ h(
1688
+ 'span',
1689
+ { className: 'dpc-glyph', role: 'img', 'aria-label': t('step.' + step.status) },
1690
+ glyph
1691
+ ),
1692
+ h('span', { className: 'dpc-step-main' }, mainChildren)
1693
+ );
1694
+ })
1695
+ )
1696
+ )
1697
+ );
1698
+ }
1699
+
1700
+ if (plan.reason) {
1701
+ children.push(
1702
+ h(
1703
+ 'div',
1704
+ { key: 'reason', className: 'dpc-section' },
1705
+ h('div', { className: 'dpc-label' }, t('popover.reason')),
1706
+ h('div', { className: 'dpc-reason' }, plan.reason)
1707
+ )
1708
+ );
1709
+ }
1710
+
1711
+ const suggestion = getSuggestion(plan);
1712
+ if (suggestion) {
1713
+ children.push(
1714
+ h(
1715
+ 'div',
1716
+ { key: 'suggestion', className: 'dpc-section dpc-suggest-card' },
1717
+ h('div', { className: 'dpc-label' }, t('popover.suggestion')),
1718
+ h('div', { className: 'dpc-suggest-line' }, suggestion.text)
1719
+ )
1720
+ );
1721
+ } else if (plan.status === 'active') {
1722
+ const fillText = planFillText(plan);
1723
+ if (fillText) {
1724
+ children.push(
1725
+ h(
1726
+ 'div',
1727
+ { key: 'suggestion', className: 'dpc-section dpc-suggest-card' },
1728
+ h('div', { className: 'dpc-label' }, t('popover.suggestion')),
1729
+ h('div', { className: 'dpc-suggest-line' }, fillText)
1730
+ )
1731
+ );
1732
+ }
1733
+ }
1734
+
1735
+ if (plan.updatedAt) {
1736
+ children.push(
1737
+ h('div', { key: 'updated', className: 'dpc-updated' }, t('popover.updated', { time: formatTime(plan.updatedAt) }))
1738
+ );
1739
+ }
1740
+
1741
+ const fillText = planFillText(plan);
1742
+ const canWrite = !!inputActions;
1743
+ children.push(
1744
+ h(
1745
+ 'div',
1746
+ { key: 'actions', className: 'dpc-actions' },
1747
+ h(
1748
+ 'button',
1749
+ {
1750
+ type: 'button',
1751
+ className: 'dpc-btn',
1752
+ 'data-primary': 'true',
1753
+ disabled: !canWrite || fillText === '',
1754
+ onMouseDown: preventDefault,
1755
+ onClick: function onClickFill(event) {
1756
+ stopPropagation(event);
1757
+ if (replaceComposerDraft(inputActions, fillText)) closeOverlay();
1758
+ }
1759
+ },
1760
+ t('popover.fill')
1761
+ ),
1762
+ h(
1763
+ 'button',
1764
+ {
1765
+ type: 'button',
1766
+ className: 'dpc-btn',
1767
+ disabled: !canWrite,
1768
+ onMouseDown: preventDefault,
1769
+ onClick: function onClickSync(event) {
1770
+ stopPropagation(event);
1771
+ const prompt = buildSyncPrompt(plan);
1772
+ if (replaceComposerDraft(inputActions, prompt)) closeOverlay();
1773
+ }
1774
+ },
1775
+ t('popover.sync')
1776
+ ),
1777
+ h(
1778
+ 'button',
1779
+ {
1780
+ type: 'button',
1781
+ className: 'dpc-btn',
1782
+ onMouseDown: preventDefault,
1783
+ onClick: function onClickClose(event) {
1784
+ stopPropagation(event);
1785
+ closeOverlay();
1786
+ }
1787
+ },
1788
+ t('popover.close')
1789
+ )
1790
+ )
1791
+ );
1792
+
1793
+ // v2 skin: regroup flat children into header / scrollable body / footer.
1794
+ if (children.length >= 2) {
1795
+ const head = children.shift();
1796
+ let updated = null;
1797
+ const last = children.length ? children[children.length - 1] : null;
1798
+ if (last && last.props && last.props.className === 'dpc-updated') updated = children.pop();
1799
+ const actions = children.pop();
1800
+ if (actions) {
1801
+ return [
1802
+ head,
1803
+ h('div', { key: 'body', className: 'dpc-pop-body' }, children),
1804
+ h('div', { key: 'foot', className: 'dpc-pop-foot' }, updated || null, actions)
1805
+ ];
1806
+ }
1807
+ }
1808
+
1809
+ return children;
1810
+ }
1811
+
1812
+ function PlanPopover(props) {
1813
+ const state = useStoreSelector(selectAll);
1814
+ const t = makeT(props);
1815
+ const rootRef = React.useRef(null);
1816
+ const open = state.overlayOpen === true;
1817
+ const [heroPos, setHeroPos] = React.useState(null);
1818
+
1819
+ // P0-2 hero fallback: when no session-bound dock is mounted (new conversation / hero,
1820
+ // or a cleared conversation), float a small «▤ 计划» entry above the composer so the
1821
+ // entry is always reachable.
1822
+ React.useEffect(function trackHeroEntry() {
1823
+ if (typeof window === 'undefined' || typeof document === 'undefined') return undefined;
1824
+ let timer = 0;
1825
+ const scan = function scan() {
1826
+ try {
1827
+ if (!DEV_HERO_PREVIEW && document.querySelector('[data-plan-completion="dock"]')) {
1828
+ setHeroPos(null);
1829
+ return;
1830
+ }
1831
+ const cards = Array.from(document.querySelectorAll('[data-composer-card]'));
1832
+ let card = null;
1833
+ for (const node of cards) {
1834
+ const rect = node.getBoundingClientRect();
1835
+ if (rect.width > 100 && rect.height > 20 && rect.top > 80) {
1836
+ card = rect;
1837
+ break;
1838
+ }
1839
+ }
1840
+ if (!card) {
1841
+ setHeroPos(null);
1842
+ return;
1843
+ }
1844
+ const next = { top: Math.max(8, Math.round(card.top - 34)), left: Math.round(card.left) };
1845
+ setHeroPos(function keep(previous) {
1846
+ return previous && previous.top === next.top && previous.left === next.left ? previous : next;
1847
+ });
1848
+ } catch (error) {
1849
+ /* ignore */
1850
+ }
1851
+ };
1852
+ scan();
1853
+ timer = window.setInterval(scan, 700);
1854
+ window.addEventListener('resize', scan);
1855
+ return function cleanup() {
1856
+ if (timer) window.clearInterval(timer);
1857
+ window.removeEventListener('resize', scan);
1858
+ };
1859
+ }, []);
1860
+
1861
+ React.useEffect(function publishPopoverRoot() {
1862
+ store.set({ popoverRoot: open ? rootRef.current : null });
1863
+ return function cleanup() {
1864
+ if (store.get().popoverRoot === rootRef.current) store.set({ popoverRoot: null });
1865
+ };
1866
+ }, [open]);
1867
+
1868
+ const styleTag = h('style', { key: 'style', 'data-plan-completion-style': 'overlay' }, POPOVER_CSS + HERO_CSS);
1869
+ const heroEntry = heroPos
1870
+ ? h(
1871
+ 'button',
1872
+ {
1873
+ key: 'hero',
1874
+ type: 'button',
1875
+ className: 'dpc-hero-entry',
1876
+ 'data-plan-completion': 'hero-entry',
1877
+ style: { top: heroPos.top + 'px', left: heroPos.left + 'px' },
1878
+ 'aria-haspopup': 'dialog',
1879
+ 'aria-expanded': open ? 'true' : 'false',
1880
+ title: t('entry.title'),
1881
+ onMouseDown: preventDefault,
1882
+ onClick: function onClickHero(event) {
1883
+ stopPropagation(event);
1884
+ openOverlay(event && event.currentTarget ? event.currentTarget : null);
1885
+ }
1886
+ },
1887
+ h('span', { key: 'icon', className: 'dpc-entry-icon', 'aria-hidden': true }, '▤'),
1888
+ h('span', { key: 'label' }, t('entry.label'))
1889
+ )
1890
+ : null;
1891
+
1892
+ if (!open) return h(React.Fragment, null, styleTag, heroEntry);
1893
+
1894
+ const position = computePopoverPosition(state.anchorRect);
1895
+ return h(
1896
+ React.Fragment,
1897
+ null,
1898
+ styleTag,
1899
+ heroEntry,
1900
+ h(
1901
+ 'div',
1902
+ {
1903
+ key: 'dialog',
1904
+ ref: rootRef,
1905
+ className: 'dpc-popover',
1906
+ role: 'dialog',
1907
+ 'aria-label': t('popover.title'),
1908
+ 'data-plan-completion': 'popover',
1909
+ style: position
1910
+ },
1911
+ renderPopoverContent(state.plan, state.inputActions, t)
1912
+ )
1913
+ );
1914
+ }
1915
+
1916
+ // =====================================================================
1917
+ // Plugin entry
1918
+ // =====================================================================
1919
+ return {
1920
+ inject: ['slots', 'locale'],
1921
+ apply(ctx) {
1922
+ try {
1923
+ ctx.effect(function registerDictionaries() {
1924
+ return ctx.locale.register(NS, { zh: MESSAGES.zh, en: MESSAGES.en });
1925
+ }, 'plan-completion: dictionaries');
1926
+ } catch (error) {
1927
+ /* locale is optional for rendering: makeT falls back to the local dictionary */
1928
+ }
1929
+ try {
1930
+ hostT = ctx.locale.bind(NS);
1931
+ } catch (error) {
1932
+ hostT = null;
1933
+ }
1934
+ try {
1935
+ pluginThemeService = ctx.get('theme');
1936
+ } catch (error) {
1937
+ pluginThemeService = null;
1938
+ }
1939
+
1940
+ ctx.slots.inject(DOCK_SLOT, function registerDock() {
1941
+ return ctx.slots.register(
1942
+ { name: DOCK_SLOT, id: DOCK_ID, order: DOCK_ORDER, locale: NS },
1943
+ Dock
1944
+ );
1945
+ });
1946
+
1947
+ ctx.slots.inject(OVERLAY_SLOT, function registerOverlay() {
1948
+ return ctx.slots.register({ name: OVERLAY_SLOT, id: OVERLAY_ID, order: OVERLAY_ORDER }, PlanPopover);
1949
+ });
1950
+
1951
+ ctx.effect(function installKeyboard() {
1952
+ if (typeof window === 'undefined') return undefined;
1953
+ const onKeyDown = function onKeyDown(event) {
1954
+ handleKeyDown(event);
1955
+ };
1956
+ window.addEventListener('keydown', onKeyDown, true);
1957
+ return function removeKeyboard() {
1958
+ window.removeEventListener('keydown', onKeyDown, true);
1959
+ };
1960
+ }, 'plan-completion: keyboard');
1961
+
1962
+ ctx.effect(function installOutsideClick() {
1963
+ if (typeof document === 'undefined') return undefined;
1964
+ const onPointerDown = function onPointerDown(event) {
1965
+ handlePointerDown(event);
1966
+ };
1967
+ document.addEventListener('pointerdown', onPointerDown, true);
1968
+ return function removeOutsideClick() {
1969
+ document.removeEventListener('pointerdown', onPointerDown, true);
1970
+ };
1971
+ }, 'plan-completion: outside-click');
1972
+
1973
+ // Diagnostics: prove which client build is live and whether the capsule UI rendered.
1974
+ try {
1975
+ if (typeof window !== 'undefined') {
1976
+ const styleOf = function styleOf(el) {
1977
+ try {
1978
+ if (!el || typeof getComputedStyle !== 'function') return null;
1979
+ const cs = getComputedStyle(el);
1980
+ const box = el.getBoundingClientRect();
1981
+ return {
1982
+ left: Math.round(box.left),
1983
+ top: Math.round(box.top),
1984
+ w: Math.round(box.width),
1985
+ h: Math.round(box.height),
1986
+ display: cs.display,
1987
+ bg: cs.backgroundColor,
1988
+ border: cs.borderTopWidth + ' ' + cs.borderTopStyle + ' ' + cs.borderTopColor,
1989
+ radius: cs.borderTopLeftRadius,
1990
+ shadow: cs.boxShadow === 'none' ? 'none' : 'yes',
1991
+ font: cs.fontSize + '/' + cs.lineHeight,
1992
+ };
1993
+ } catch (error) {
1994
+ return { error: String(error) };
1995
+ }
1996
+ };
1997
+ const postDiag = function postDiag(phase) {
1998
+ try {
1999
+ const pill = document.querySelector('.dpc-pill');
2000
+ const entry = document.querySelector('[data-plan-completion-entry]');
2001
+ const row = document.querySelector('[data-plan-completion="dock"]');
2002
+ const suggest = document.querySelector('.dpc-suggest-pill');
2003
+ const hero = document.querySelector('[data-plan-completion="hero-entry"]');
2004
+ fetch('/plan-completion/diag', {
2005
+ method: 'POST',
2006
+ credentials: 'same-origin',
2007
+ headers: { 'content-type': 'application/json' },
2008
+ body: JSON.stringify({
2009
+ phase: phase,
2010
+ build: CLIENT_BUILD,
2011
+ hasPill: !!pill,
2012
+ hasEntry: !!entry,
2013
+ suggestVisible: !!suggest,
2014
+ dockPresent: !!row,
2015
+ heroVisible: !!hero,
2016
+ heroStyle: styleOf(hero),
2017
+ rowHeight: row ? Math.round(row.getBoundingClientRect().height) : null,
2018
+ rowStyle: styleOf(row),
2019
+ suggestStyle: styleOf(suggest),
2020
+ entryStyle: styleOf(entry),
2021
+ composerStyle: styleOf(document.querySelector('[data-composer-card]')),
2022
+ }),
2023
+ }).catch(function () {});
2024
+ } catch (error) {
2025
+ /* ignore */
2026
+ }
2027
+ };
2028
+ postDiag('module-load');
2029
+ window.setTimeout(function later() { postDiag('dom-check'); }, 2500);
2030
+ }
2031
+ } catch (error) {
2032
+ /* ignore */
2033
+ }
2034
+ }
2035
+ };
2036
+ }
2037
+ });
2038
+
2039
+ // hot-reload marker 2026-11-01T13:57Z
2040
+
1995
2041