@huanlin/dsh-plugin-better-plan 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js ADDED
@@ -0,0 +1,869 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "@huanlin/dsh-plugin-better-plan",
3
+ factory: (require) => {
4
+ var module = { exports: {} };
5
+ var exports = module.exports;
6
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
7
+ let react = require("react");
8
+ let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
9
+ //#region src/client/icons.tsx
10
+ /**
11
+ * Inline SVG icon for the Plan tab (client bundles must not value-import
12
+ * other plugins' internals, and a one-glyph icon does not justify a
13
+ * react-icons dependency).
14
+ *
15
+ * @module @huanlin/dsh-plugin-better-plan/client/icons
16
+ */
17
+ /**
18
+ * The Plan tab glyph: a checklist document.
19
+ * @param props - size in pixels.
20
+ * @returns the icon element.
21
+ */
22
+ function IconPlanOutline16(props) {
23
+ return (0, react.createElement)("svg", {
24
+ width: props.size,
25
+ height: props.size,
26
+ viewBox: "0 0 16 16",
27
+ fill: "none",
28
+ stroke: "currentColor",
29
+ strokeWidth: 1.3,
30
+ strokeLinecap: "round",
31
+ strokeLinejoin: "round",
32
+ "aria-hidden": true
33
+ }, (0, react.createElement)("path", { d: "M9.5 1.5H4a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1V5l-3.5-3.5Z" }), (0, react.createElement)("path", { d: "M9.5 1.5V5H13" }), (0, react.createElement)("path", { d: "M5.5 8.5 6.5 9.5 8.5 7" }), (0, react.createElement)("path", { d: "M5.5 11.5h5" }));
34
+ }
35
+ //#endregion
36
+ //#region src/client/locales.ts
37
+ /**
38
+ * Client-half copy for the Plan tab, following the DSH i18n system: the
39
+ * dictionaries register into the shared locale registry (namespace
40
+ * `betterPlan`), and `t()` resolves the active locale from the attached
41
+ * `ctx.locale` service (`@deepseek-ai/dsh-client-locale`) — the Host-backed
42
+ * `locale.preference` wins over the raw browser language and switches live.
43
+ * Absent the service (locale plugin not mounted), the browser language is
44
+ * the fallback.
45
+ *
46
+ * The module-level attach mirrors better-sidebar's own locales.ts: the Plan
47
+ * tab renders inside the sidebar's React tree, so the component reads copy
48
+ * through `t()` at render time; the sidebar re-renders tab content on locale
49
+ * switches (its tab-content memo keys on the locale revision), so no extra
50
+ * subscription is needed here.
51
+ *
52
+ * @module @huanlin/dsh-plugin-better-plan/client/locales
53
+ */
54
+ /** The zh dictionary (source of truth for the key set). */
55
+ const zhDict = {
56
+ tabTitle: "计划",
57
+ openInEditor: "在编辑器中打开",
58
+ copyPath: "复制路径",
59
+ copied: "已复制",
60
+ copy: "复制",
61
+ copiedLabel: "已复制",
62
+ reviewHint: "在此审阅计划——聊天中不会弹出审批卡。点「批准」在本对话执行,点「新开对话执行」移到全新对话执行,或附反馈选择「继续规划」。",
63
+ feedbackPlaceholder: "「继续规划」时可附反馈(可选)…",
64
+ approve: "批准",
65
+ keepPlanning: "继续规划",
66
+ approveNewSession: "新开对话执行",
67
+ delegatingStatus: "计划已批准——正在新建执行对话…",
68
+ delegatedStatus: "计划已批准——执行已移交到新对话。",
69
+ errDelegateFailed: "新开执行对话失败",
70
+ approvedStatus: "计划已批准——模型正在执行该计划。",
71
+ keptStatus: "反馈已发送——模型正在修改计划。",
72
+ loading: "正在读取计划…",
73
+ readFailed: "读取计划失败",
74
+ retry: "重试",
75
+ truncated: "文件因侧边栏读取上限被截断;其余内容请在编辑器中查看。",
76
+ noPath: "该计划标签页未携带文件路径",
77
+ binaryFile: "计划文件是二进制文件;请在编辑器中查看",
78
+ unexpectedRead: "fs.read 响应格式异常",
79
+ errStale: "当前面板已过期——有更新的计划正在审批。",
80
+ errNoPending: "当前没有等待审批的计划。",
81
+ errSubmitFailed: "提交决定失败"
82
+ };
83
+ /** The en dictionary (key-set-equal to zh, enforced by the type annotation). */
84
+ const enDict = {
85
+ tabTitle: "Plan",
86
+ openInEditor: "Open in editor",
87
+ copyPath: "Copy path",
88
+ copied: "Copied",
89
+ copy: "Copy",
90
+ copiedLabel: "Copied",
91
+ reviewHint: "Review this plan here — the chat shows no approval popup. Approve to execute in this conversation, execute in a new chat, or keep planning with feedback.",
92
+ feedbackPlaceholder: "Optional feedback for \"Keep planning\"…",
93
+ approve: "Approve",
94
+ keepPlanning: "Keep planning",
95
+ approveNewSession: "Execute in new chat",
96
+ delegatingStatus: "Plan approved — starting the execution conversation…",
97
+ delegatedStatus: "Plan approved — execution continues in a new conversation.",
98
+ errDelegateFailed: "Failed to start the execution conversation",
99
+ approvedStatus: "Plan approved — the model is carrying out the plan.",
100
+ keptStatus: "Feedback sent — the model is revising the plan.",
101
+ loading: "Loading plan…",
102
+ readFailed: "Failed to read the plan",
103
+ retry: "Retry",
104
+ truncated: "The file was truncated by the sidebar read limit; open it in the editor for the rest.",
105
+ noPath: "this plan tab carries no file path",
106
+ binaryFile: "the plan file is binary; review it in the editor instead",
107
+ unexpectedRead: "unexpected fs.read response",
108
+ errStale: "This panel is stale — a newer plan delivery is under review.",
109
+ errNoPending: "No plan is awaiting review.",
110
+ errSubmitFailed: "Submitting the decision failed"
111
+ };
112
+ const DICTS = {
113
+ zh: zhDict,
114
+ en: enDict
115
+ };
116
+ /** The locale namespace this plugin owns in the DSH locale registry. */
117
+ const LOCALE_NS = "betterPlan";
118
+ /** The DSH locale service attached by the client apply (absent → browser detection). */
119
+ let localeService;
120
+ /**
121
+ * Attach (or detach, with undefined) the DSH locale service.
122
+ * @param service - the client context's locale service.
123
+ */
124
+ function attachLocale(service) {
125
+ localeService = service;
126
+ }
127
+ /**
128
+ * The active copy locale: the DSH locale service's snapshot when attached
129
+ * (zh → zh, anything else → en), else the browser language.
130
+ * @returns `'zh'` or `'en'`.
131
+ */
132
+ function activeLocale() {
133
+ const active = localeService?.getSnapshot().active;
134
+ if (active !== void 0) return active === "zh" ? "zh" : "en";
135
+ return (typeof navigator !== "undefined" ? navigator.language : "en").toLowerCase().startsWith("zh") ? "zh" : "en";
136
+ }
137
+ /**
138
+ * Translate one copy key in the active locale.
139
+ * @param key - the copy key.
140
+ * @returns the localized string.
141
+ */
142
+ function t(key) {
143
+ return DICTS[activeLocale()][key];
144
+ }
145
+ /**
146
+ * The kickoff prompt the delegation flow queues into the NEW conversation:
147
+ * the model there has no context, so the message anchors it on the approved
148
+ * plan file (an absolute path) and orders execution. It is both
149
+ * user-visible (a chat bubble) and model-directed, so it localizes with the
150
+ * view like the steer copy does.
151
+ * @param path - the absolute plan file path.
152
+ * @returns the kickoff prompt text.
153
+ */
154
+ function executionKickoffPrompt(path) {
155
+ if (activeLocale() === "zh") return `[计划执行] 用户已在上一对话中制定并批准了以下计划文件:${path}。这是一个全新对话——请先读取该计划文件,然后严格按计划开始执行,无需重新规划或再次确认。`;
156
+ return `[Plan execution] The user planned and approved the following plan file in a previous conversation: ${path}. This is a fresh conversation — read that plan file first, then carry it out exactly as written; no re-planning or re-confirmation needed.`;
157
+ }
158
+ /**
159
+ * Localize one review-route error for the action bar: known error codes map
160
+ * to copy; unknown codes fall back to the route's raw English message.
161
+ * @param code - the route's stable error code, when present.
162
+ * @param raw - the route's raw error message (or an HTTP fallback).
163
+ * @returns the text the action bar shows.
164
+ */
165
+ function submitErrorText(code, raw) {
166
+ if (code === "stale_review") return t("errStale");
167
+ if (code === "no_pending") return t("errNoPending");
168
+ if (code === void 0 || code === "") return `${t("errSubmitFailed")}: ${raw}`;
169
+ return `${t("errSubmitFailed")}: ${raw}`;
170
+ }
171
+ //#endregion
172
+ //#region src/client/markdown-props.ts
173
+ /** Build the dual-shape chrome labels from a flat copy-button pair. */
174
+ function markdownChromeLabels(labels) {
175
+ return {
176
+ copyLabel: labels.copyLabel,
177
+ copiedLabel: labels.copiedLabel,
178
+ code: {
179
+ copyLabel: labels.copyLabel,
180
+ copiedLabel: labels.copiedLabel
181
+ },
182
+ footnotes: ""
183
+ };
184
+ }
185
+ /**
186
+ * MarkdownText props carrying the labels under BOTH prop names. The cast is
187
+ * load-bearing: this plugin builds against one generation's declaration where
188
+ * the other prop does not exist.
189
+ * @param text - the markdown source.
190
+ * @param labels - the flat copy-button pair.
191
+ * @returns props spreadable onto `MarkdownText`.
192
+ */
193
+ function markdownTextProps(text, labels) {
194
+ const chrome = markdownChromeLabels(labels);
195
+ return {
196
+ text,
197
+ codeLabels: chrome,
198
+ labels: chrome
199
+ };
200
+ }
201
+ //#endregion
202
+ //#region src/client/execution-launch.ts
203
+ /**
204
+ * The delegation flow's client half: after a review settles as
205
+ * `delegated`, create the execution conversation through DSH's public
206
+ * sessions service (`ctx.get('sessions')`), queue the kickoff prompt into
207
+ * it, and navigate there.
208
+ *
209
+ * The faces below are STRUCTURAL (the minimal subset of
210
+ * `@deepseek-ai/dsh-api-session-controller/client`'s `ISessions` /
211
+ * `SessionFace` this flow calls) — the same pattern better-sidebar uses for
212
+ * cross-plugin services: no value import crosses the client-bundle purity
213
+ * gate and no new tsconfig path is needed; the runtime service arrives
214
+ * through the context proxy. The planning session's list summary supplies
215
+ * the new conversation's cwd, so the fresh agent runs on the same
216
+ * workspace the plan was written for.
217
+ *
218
+ * @module @huanlin/dsh-plugin-better-plan/client/execution-launch
219
+ */
220
+ /**
221
+ * Launch the execution conversation for a delegated plan: create a blank
222
+ * session in the planning session's workspace, queue the kickoff prompt
223
+ * pointing at the approved plan file, then navigate to the new
224
+ * conversation. Every failure rejects with an Error whose message the
225
+ * plan panel shows (the plan path stays visible there, so the user can
226
+ * always start a conversation and send the plan manually).
227
+ * @param sessions - the sessions service (`ctx.get('sessions')`).
228
+ * @param planPath - the approved plan file's absolute path.
229
+ * @param sessionId - the planning session (its summary carries the cwd).
230
+ */
231
+ async function launchExecutionConversation(sessions, planPath, sessionId) {
232
+ const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd;
233
+ const newId = await sessions.create(cwd === void 0 ? {} : { cwd });
234
+ const binding = sessions.binding(newId);
235
+ if (binding === void 0) throw new Error(`the new session "${newId}" is not locally addressable yet`);
236
+ const admitted = await binding.session.prompt([{
237
+ type: "text",
238
+ text: executionKickoffPrompt(planPath)
239
+ }], "queue");
240
+ if (!admitted.ok) throw new Error(`the kickoff prompt was rejected: ${admitted.error?.code ?? "unknown"}: ${admitted.error?.message ?? ""}`);
241
+ sessions.open(newId);
242
+ }
243
+ //#endregion
244
+ //#region src/client/review-store.ts
245
+ /**
246
+ * The client-side review state: a tiny external store fed by the delivery
247
+ * WebSocket's `{ kind: 'review', review }` frames and by the decision POST's
248
+ * echoed response, consumed by the Plan tab's action bar through
249
+ * `useSyncExternalStore`.
250
+ *
251
+ * The state mirrors the host gate's per-session review; a session switch
252
+ * resets it (the reconnect's attach replay restores the current state).
253
+ *
254
+ * @module @huanlin/dsh-plugin-better-plan/client/review-store
255
+ */
256
+ /** Whether an unknown wire value is a well-formed review state. */
257
+ function isReviewState(value) {
258
+ if (value === null || typeof value !== "object") return false;
259
+ const record = value;
260
+ return typeof record.id === "string" && record.id !== "" && typeof record.path === "string" && record.path !== "" && typeof record.title === "string" && (record.status === "pending" || record.status === "approved" || record.status === "delegated" || record.status === "kept" || record.status === "cancelled");
261
+ }
262
+ /** External store for the current session's review state. */
263
+ var ReviewStore = class {
264
+ state = null;
265
+ listeners = /* @__PURE__ */ new Set();
266
+ /** The current review state (null = nothing to review). */
267
+ get = () => this.state;
268
+ /** Replace the state and notify subscribers. */
269
+ set = (next) => {
270
+ this.state = next;
271
+ for (const listener of this.listeners) listener();
272
+ };
273
+ /** Clear the state (session switch); attach replay restores it. */
274
+ reset = () => {
275
+ this.set(null);
276
+ };
277
+ /** @returns the unsubscribe disposer. */
278
+ subscribe = (listener) => {
279
+ this.listeners.add(listener);
280
+ return () => {
281
+ this.listeners.delete(listener);
282
+ };
283
+ };
284
+ };
285
+ /** The singleton store the WS handler writes and the Plan tab reads. */
286
+ const reviewStore = new ReviewStore();
287
+ /** The exact pathname of the host's review decision route. */
288
+ const REVIEW_API_PATH = "/better-plan/api/review";
289
+ /**
290
+ * Bootstrap the review state over HTTP (the WS attach replay remains the
291
+ * live channel): fills the bar when a review frame was missed (stale bundle,
292
+ * reconnect gap). A live frame already in the store wins — the GET result is
293
+ * only applied while the store is empty, so a late null response can never
294
+ * clear a pending bar. The request reports the view's active locale so the
295
+ * host's user-facing copy follows the browser.
296
+ * @param sessionId - the session whose review state to read.
297
+ */
298
+ async function fetchReviewState(sessionId) {
299
+ try {
300
+ const url = `${REVIEW_API_PATH}?session=${encodeURIComponent(sessionId)}&locale=${encodeURIComponent(activeLocale())}`;
301
+ const response = await fetch(url);
302
+ const parsed = await response.json().catch(() => null);
303
+ if (!response.ok || parsed === null || parsed.ok !== true) return;
304
+ if (reviewStore.get() !== null) return;
305
+ if (parsed.review === null) reviewStore.set(null);
306
+ else if (isReviewState(parsed.review)) reviewStore.set(parsed.review);
307
+ } catch {}
308
+ }
309
+ /**
310
+ * Post one review decision to the host route and, on success, echo the
311
+ * settled state into the store (the submitting view updates immediately;
312
+ * other views follow over the WebSocket). The body reports the view's active
313
+ * locale; failures map the route's stable error codes to localized copy.
314
+ * @param sessionId - the session whose plan is under review.
315
+ * @param decision - the user's choice.
316
+ * @param feedback - optional keep-planning feedback.
317
+ * @param reviewId - the reviewed delivery's id (stale-click guard).
318
+ * @returns the outcome; failures keep the pending bar up for a retry.
319
+ */
320
+ async function submitReviewDecision(sessionId, decision, feedback, reviewId) {
321
+ try {
322
+ const response = await fetch(REVIEW_API_PATH, {
323
+ method: "POST",
324
+ headers: { "content-type": "application/json" },
325
+ body: JSON.stringify({
326
+ session: sessionId,
327
+ decision,
328
+ locale: activeLocale(),
329
+ ...decision === "keep" && feedback !== void 0 && feedback !== "" ? { feedback } : {},
330
+ id: reviewId
331
+ })
332
+ });
333
+ const parsed = await response.json().catch(() => null);
334
+ if (!response.ok || parsed === null || parsed.ok !== true) {
335
+ const raw = typeof parsed?.error === "string" ? parsed.error : `HTTP ${response.status}`;
336
+ return {
337
+ ok: false,
338
+ error: submitErrorText(typeof parsed?.code === "string" ? parsed.code : void 0, raw)
339
+ };
340
+ }
341
+ if (!isReviewState(parsed.review)) return {
342
+ ok: false,
343
+ error: t("errSubmitFailed")
344
+ };
345
+ reviewStore.set(parsed.review);
346
+ return {
347
+ ok: true,
348
+ review: parsed.review
349
+ };
350
+ } catch (cause) {
351
+ return {
352
+ ok: false,
353
+ error: cause instanceof Error ? cause.message : String(cause)
354
+ };
355
+ }
356
+ }
357
+ //#endregion
358
+ //#region src/client/PlanView.tsx
359
+ /**
360
+ * The Plan tab view: a status header (title, path, actions), the review
361
+ * action bar (the sidebar approval surface — the chat shows no popup), and
362
+ * the plan rendered through DSH's shared `MarkdownText`.
363
+ *
364
+ * The file content comes from better-sidebar's `/sidebar/api/fs.read` route
365
+ * (same-origin, browser-authenticated) — the plan file lives in the session
366
+ * workspace, and the host half has no route of its own for reading it. The
367
+ * path is `tab.meta.path` (persisted with the tab, so a refresh restores the
368
+ * view) falling back to `tab.path`.
369
+ *
370
+ * The review state comes from the delivery WebSocket's review frames (via
371
+ * the shared review store, restored by the attach replay after a refresh).
372
+ * While a review is pending the bar offers Approve / Execute in new chat /
373
+ * Keep planning; a decision POSTs to the host's review route and the echo
374
+ * updates the store. The delegation choice then launches the execution
375
+ * conversation through the sessions service (see execution-launch.ts) and
376
+ * navigates there.
377
+ *
378
+ * @module @huanlin/dsh-plugin-better-plan/client/PlanView
379
+ */
380
+ /**
381
+ * Extract the plan path from a tab (meta first, then the seed path).
382
+ * @param tab - the sidebar tab instance.
383
+ * @returns the plan file path, or undefined when the tab carries none.
384
+ */
385
+ function planPathOf(tab) {
386
+ const meta = tab.meta;
387
+ if (meta !== null && typeof meta === "object" && typeof meta.path === "string" && meta.path !== "") return meta.path;
388
+ return typeof tab.path === "string" && tab.path !== "" ? tab.path : void 0;
389
+ }
390
+ /** Read the sidebar fs API for one session-scoped path. */
391
+ async function fsRead(sessionId, path, signal) {
392
+ const response = await fetch("/sidebar/api/fs.read", {
393
+ method: "POST",
394
+ headers: { "content-type": "application/json" },
395
+ body: JSON.stringify({
396
+ sessionId,
397
+ path
398
+ }),
399
+ signal
400
+ });
401
+ const parsed = await response.json().catch(() => null);
402
+ if (!response.ok || parsed === null || parsed.ok !== true || parsed.value === void 0) throw new Error(parsed?.error?.message ?? `HTTP ${response.status}`);
403
+ const value = parsed.value;
404
+ if (value.kind === "binary") throw new Error(t("binaryFile"));
405
+ if (typeof value.content !== "string") throw new Error(t("unexpectedRead"));
406
+ return {
407
+ content: value.content,
408
+ truncated: value.truncated === true
409
+ };
410
+ }
411
+ /** Shared style tokens (alias tokens with plain fallbacks, no hardcoded theme). */
412
+ const styles = {
413
+ root: {
414
+ display: "flex",
415
+ flexDirection: "column",
416
+ height: "100%",
417
+ minHeight: 0,
418
+ overflow: "hidden"
419
+ },
420
+ header: {
421
+ display: "flex",
422
+ flexDirection: "column",
423
+ gap: 4,
424
+ padding: "10px 14px",
425
+ borderBottom: "1px solid var(--ds-border, rgba(127,127,127,0.25))",
426
+ background: "var(--dsw-alias-bg-layer-1, transparent)"
427
+ },
428
+ titleRow: {
429
+ display: "flex",
430
+ alignItems: "center",
431
+ gap: 8,
432
+ minWidth: 0
433
+ },
434
+ title: {
435
+ fontSize: 13,
436
+ fontWeight: 600,
437
+ overflow: "hidden",
438
+ textOverflow: "ellipsis",
439
+ whiteSpace: "nowrap"
440
+ },
441
+ actions: {
442
+ display: "flex",
443
+ gap: 6,
444
+ marginLeft: "auto",
445
+ flexShrink: 0
446
+ },
447
+ button: {
448
+ fontSize: 11,
449
+ padding: "3px 8px",
450
+ borderRadius: 6,
451
+ cursor: "pointer",
452
+ border: "1px solid var(--ds-border, rgba(127,127,127,0.35))",
453
+ background: "transparent",
454
+ color: "inherit"
455
+ },
456
+ primaryButton: {
457
+ fontSize: 12,
458
+ padding: "5px 12px",
459
+ borderRadius: 6,
460
+ cursor: "pointer",
461
+ fontWeight: 600,
462
+ border: "1px solid var(--ds-border-strong, var(--ds-border, rgba(127,127,127,0.5)))",
463
+ background: "transparent",
464
+ color: "inherit"
465
+ },
466
+ path: {
467
+ fontSize: 11,
468
+ fontFamily: "var(--ds-font-mono, ui-monospace, monospace)",
469
+ color: "var(--ds-text-muted, var(--ds-text-3, #888))",
470
+ overflow: "hidden",
471
+ textOverflow: "ellipsis",
472
+ whiteSpace: "nowrap"
473
+ },
474
+ reviewBar: {
475
+ display: "flex",
476
+ flexDirection: "column",
477
+ gap: 8,
478
+ padding: "10px 14px",
479
+ borderBottom: "1px solid var(--ds-border, rgba(127,127,127,0.25))",
480
+ background: "var(--dsw-alias-bg-layer-2, transparent)"
481
+ },
482
+ reviewText: {
483
+ fontSize: 12,
484
+ lineHeight: 1.55
485
+ },
486
+ reviewInput: {
487
+ fontSize: 12,
488
+ padding: "5px 8px",
489
+ borderRadius: 6,
490
+ border: "1px solid var(--ds-border, rgba(127,127,127,0.35))",
491
+ background: "transparent",
492
+ color: "inherit",
493
+ width: "100%",
494
+ boxSizing: "border-box"
495
+ },
496
+ reviewButtons: {
497
+ display: "flex",
498
+ gap: 8
499
+ },
500
+ reviewError: {
501
+ fontSize: 11,
502
+ color: "var(--ds-text-critical, #b3261e)"
503
+ },
504
+ reviewStatus: {
505
+ padding: "8px 14px",
506
+ fontSize: 12,
507
+ lineHeight: 1.55,
508
+ borderBottom: "1px solid var(--ds-border, rgba(127,127,127,0.25))"
509
+ },
510
+ body: {
511
+ flex: 1,
512
+ minHeight: 0,
513
+ overflow: "auto",
514
+ padding: "14px 16px",
515
+ fontSize: 13,
516
+ lineHeight: 1.65
517
+ },
518
+ notice: {
519
+ padding: "14px 16px",
520
+ fontSize: 12,
521
+ lineHeight: 1.6
522
+ }
523
+ };
524
+ /** The one-line status a settled review renders (cancelled stays quiet). */
525
+ function settledReviewText(status, delegating) {
526
+ if (status === "approved") return t("approvedStatus");
527
+ if (status === "delegated") return delegating ? t("delegatingStatus") : t("delegatedStatus");
528
+ if (status === "kept") return t("keptStatus");
529
+ return null;
530
+ }
531
+ /**
532
+ * The Plan tab component (better-sidebar TabDescriptor.component).
533
+ * @param props - the tab component props (ctx/store/scope/tab/visible).
534
+ * @returns the rendered view.
535
+ */
536
+ function PlanView(props) {
537
+ const { ctx, scope, tab } = props;
538
+ const path = planPathOf(tab);
539
+ const [load, setLoad] = (0, react.useState)({ status: "loading" });
540
+ const [attempt, setAttempt] = (0, react.useState)(0);
541
+ const [copied, setCopied] = (0, react.useState)(false);
542
+ const review = (0, react.useSyncExternalStore)(reviewStore.subscribe, reviewStore.get);
543
+ const [feedback, setFeedback] = (0, react.useState)("");
544
+ const [submitting, setSubmitting] = (0, react.useState)(false);
545
+ const [submitError, setSubmitError] = (0, react.useState)(void 0);
546
+ const [delegating, setDelegating] = (0, react.useState)(false);
547
+ const [delegateError, setDelegateError] = (0, react.useState)(void 0);
548
+ const sessions = ctx.get("sessions");
549
+ const pendingReview = review !== null && review.status === "pending" && review.path === path ? review : null;
550
+ const settledReview = review !== null && review.path === path && review.status !== "pending" ? settledReviewText(review.status, delegating) : null;
551
+ (0, react.useEffect)(() => {
552
+ setFeedback("");
553
+ setSubmitError(void 0);
554
+ setDelegateError(void 0);
555
+ }, [review?.id]);
556
+ (0, react.useEffect)(() => {
557
+ fetchReviewState(scope.sessionId);
558
+ }, [scope.sessionId]);
559
+ (0, react.useEffect)(() => {
560
+ if (path === void 0) {
561
+ setLoad({
562
+ status: "error",
563
+ message: t("noPath")
564
+ });
565
+ return;
566
+ }
567
+ const controller = new AbortController();
568
+ setLoad({ status: "loading" });
569
+ fsRead(scope.sessionId, path, controller.signal).then((result) => {
570
+ setLoad({
571
+ status: "ok",
572
+ ...result
573
+ });
574
+ }).catch((cause) => {
575
+ if (controller.signal.aborted) return;
576
+ setLoad({
577
+ status: "error",
578
+ message: cause instanceof Error ? cause.message : String(cause)
579
+ });
580
+ });
581
+ return () => {
582
+ controller.abort();
583
+ };
584
+ }, [
585
+ path,
586
+ scope.sessionId,
587
+ attempt
588
+ ]);
589
+ const betterSidebar = ctx.get("betterSidebar");
590
+ const canOpenInEditor = path !== void 0 && Array.isArray(betterSidebar?.features) === true && betterSidebar.features.includes("openFile") === true;
591
+ const copyPath = (0, react.useCallback)(() => {
592
+ if (path === void 0) return;
593
+ (0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(path);
594
+ setCopied(true);
595
+ window.setTimeout(() => {
596
+ setCopied(false);
597
+ }, 1500);
598
+ }, [path]);
599
+ const decide = (0, react.useCallback)((decision) => {
600
+ if (pendingReview === null) return;
601
+ setSubmitting(true);
602
+ setSubmitError(void 0);
603
+ submitReviewDecision(scope.sessionId, decision, decision === "keep" ? feedback : void 0, pendingReview.id).then((outcome) => {
604
+ if (outcome.ok === false) {
605
+ setSubmitError(outcome.error);
606
+ return;
607
+ }
608
+ if (decision !== "approve_new_session") return;
609
+ if (sessions === void 0) return;
610
+ setDelegating(true);
611
+ launchExecutionConversation(sessions, pendingReview.path, scope.sessionId).catch((cause) => {
612
+ setDelegateError(`${t("errDelegateFailed")}: ${cause instanceof Error ? cause.message : String(cause)}`);
613
+ }).finally(() => {
614
+ setDelegating(false);
615
+ });
616
+ }).finally(() => {
617
+ setSubmitting(false);
618
+ });
619
+ }, [
620
+ pendingReview,
621
+ scope.sessionId,
622
+ feedback,
623
+ sessions
624
+ ]);
625
+ return (0, react.createElement)("div", { style: styles.root }, (0, react.createElement)("div", { style: styles.header }, (0, react.createElement)("div", { style: styles.titleRow }, (0, react.createElement)("span", {
626
+ style: styles.title,
627
+ title: path ?? void 0
628
+ }, tab.title), (0, react.createElement)("span", { style: styles.actions }, canOpenInEditor && betterSidebar !== void 0 && path !== void 0 ? (0, react.createElement)("button", {
629
+ style: styles.button,
630
+ onClick: () => {
631
+ betterSidebar.openFile(scope, path);
632
+ }
633
+ }, t("openInEditor")) : null, path !== void 0 ? (0, react.createElement)("button", {
634
+ style: styles.button,
635
+ onClick: copyPath
636
+ }, copied ? t("copied") : t("copyPath")) : null)), path !== void 0 ? (0, react.createElement)("div", { style: styles.path }, path) : null), pendingReview !== null ? (0, react.createElement)("div", { style: styles.reviewBar }, (0, react.createElement)("div", { style: styles.reviewText }, t("reviewHint")), (0, react.createElement)("input", {
637
+ style: styles.reviewInput,
638
+ value: feedback,
639
+ placeholder: t("feedbackPlaceholder"),
640
+ onChange: (event) => {
641
+ setFeedback(event.target.value);
642
+ }
643
+ }), (0, react.createElement)("div", { style: styles.reviewButtons }, (0, react.createElement)("button", {
644
+ style: styles.primaryButton,
645
+ disabled: submitting,
646
+ onClick: () => {
647
+ decide("approve");
648
+ }
649
+ }, t("approve")), sessions !== void 0 ? (0, react.createElement)("button", {
650
+ style: styles.primaryButton,
651
+ disabled: submitting,
652
+ onClick: () => {
653
+ decide("approve_new_session");
654
+ }
655
+ }, t("approveNewSession")) : null, (0, react.createElement)("button", {
656
+ style: styles.button,
657
+ disabled: submitting,
658
+ onClick: () => {
659
+ decide("keep");
660
+ }
661
+ }, t("keepPlanning"))), submitError !== void 0 ? (0, react.createElement)("div", { style: styles.reviewError }, submitError) : null) : settledReview !== null ? (0, react.createElement)("div", { style: styles.reviewStatus }, settledReview, delegateError !== void 0 ? (0, react.createElement)("div", { style: styles.reviewError }, delegateError) : null) : null, load.status === "loading" ? (0, react.createElement)("div", { style: styles.notice }, t("loading")) : load.status === "error" ? (0, react.createElement)("div", { style: styles.notice }, (0, react.createElement)("div", null, `${t("readFailed")}: ${load.message}`), path !== void 0 ? (0, react.createElement)("button", {
662
+ style: {
663
+ ...styles.button,
664
+ marginTop: 8
665
+ },
666
+ onClick: () => {
667
+ setAttempt(attempt + 1);
668
+ }
669
+ }, t("retry")) : null) : (0, react.createElement)("div", { style: styles.body }, (0, react.createElement)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, { ...markdownTextProps(load.content, {
670
+ copyLabel: t("copy"),
671
+ copiedLabel: t("copiedLabel")
672
+ }) }), load.truncated ? (0, react.createElement)("div", { style: {
673
+ ...styles.notice,
674
+ paddingLeft: 0,
675
+ paddingRight: 0
676
+ } }, t("truncated")) : null));
677
+ }
678
+ //#endregion
679
+ //#region src/client/index.tsx
680
+ /**
681
+ * Client half of @huanlin/dsh-plugin-better-plan: registers the sidebar's
682
+ * "Plan" tab through better-sidebar's service and subscribes the per-session
683
+ * delivery WebSocket that opens it and feeds its review action bar.
684
+ *
685
+ * The tab is `single: true` (one Plan tab per session, dedupe-focused on
686
+ * repeat deliveries). Because the dedupe focus does NOT overwrite an already
687
+ * open tab's path, every push is followed by `updateTab` (feature-gated,
688
+ * v0.12.0+) so a re-delivered plan replaces the tab's content, then
689
+ * `activateTab` focuses it. `meta` rides the tab into better-sidebar's
690
+ * localStorage persistence, so a refresh restores the view and PlanView
691
+ * re-reads the file from `tab.meta.path`. Review frames feed the shared
692
+ * review store; the tab's action bar posts decisions back to
693
+ * `POST /better-plan/api/review` — the sidebar IS the approval surface, the
694
+ * chat shows no popup.
695
+ *
696
+ * With better-sidebar absent this half stays pending on its inject (legal
697
+ * per the client runner) and, defensively, apply() skips everything when the
698
+ * service is unreachable — the host half's `delivered: false` path covers
699
+ * the no-sidebar deployment.
700
+ *
701
+ * @module @huanlin/dsh-plugin-better-plan/client
702
+ */
703
+ /** The Plan tab type id (also the minted tab id — single instance). */
704
+ const TAB_ID = "better-plan:plan";
705
+ /** The delivery WebSocket path (mirror of the host half's route). */
706
+ const DELIVERY_WS_PATH = "/better-plan/ws/delivery";
707
+ /** Reconnect attempts before the loop stops (mirrors the sidebar's socket loops). */
708
+ const FAILURE_LIMIT = 5;
709
+ /**
710
+ * Apply one delivery push to the sidebar: open (or focus) the Plan tab, then
711
+ * overwrite its content via updateTab and focus it (both v0.12.0+; on an
712
+ * older host the plain openTab dedupe-focus still lands the FIRST delivery).
713
+ * @param service - the better-sidebar service.
714
+ * @param payload - the parsed WS frame.
715
+ * @param sessionId - the session the socket is subscribed to.
716
+ */
717
+ function applyDeliveryPush(service, payload, sessionId) {
718
+ if (payload === null || typeof payload !== "object") return;
719
+ const record = payload;
720
+ if (typeof record.path !== "string" || record.path === "") return;
721
+ const title = typeof record.title === "string" && record.title !== "" ? record.title : void 0;
722
+ const meta = {
723
+ path: record.path,
724
+ deliveredAt: Date.now()
725
+ };
726
+ const scope = { sessionId };
727
+ service.openTab({
728
+ type: TAB_ID,
729
+ id: TAB_ID,
730
+ path: record.path,
731
+ title,
732
+ meta
733
+ }, scope);
734
+ const features = service.features;
735
+ if (Array.isArray(features) === true && features.includes("updateTab") === true) {
736
+ service.updateTab(TAB_ID, {
737
+ ...title !== void 0 ? { title } : {},
738
+ path: record.path,
739
+ meta
740
+ });
741
+ service.activateTab(TAB_ID, scope);
742
+ }
743
+ }
744
+ /**
745
+ * Route one WS frame: `deliver` opens/updates the Plan tab, `review` feeds
746
+ * the review store (the Plan tab's action bar). Malformed frames are ignored
747
+ * (the next push carries its own state).
748
+ * @param service - the better-sidebar service.
749
+ * @param store - the review store the review frames feed.
750
+ * @param frame - the parsed WS frame.
751
+ * @param sessionId - the session the socket is subscribed to.
752
+ */
753
+ function applyDeliveryFrame(service, store, frame, sessionId) {
754
+ if (frame === null || typeof frame !== "object") return;
755
+ const kind = frame.kind;
756
+ if (kind === "review") {
757
+ const review = frame.review;
758
+ if (review === null) store.set(null);
759
+ else if (isReviewState(review)) store.set(review);
760
+ return;
761
+ }
762
+ if (kind === "deliver") applyDeliveryPush(service, frame, sessionId);
763
+ }
764
+ /** The betterSidebar and locale services this half resolves through the context proxy. */
765
+ const inject = ["betterSidebar", "locale"];
766
+ /**
767
+ * Client plugin body.
768
+ * @param ctx - the client cordis context.
769
+ */
770
+ function apply(ctx) {
771
+ const betterSidebar = ctx.betterSidebar;
772
+ if (betterSidebar === void 0) return;
773
+ attachLocale(ctx.locale);
774
+ ctx.effect(() => {
775
+ const offZh = ctx.locale.register(LOCALE_NS, "zh", zhDict);
776
+ const offEn = ctx.locale.register(LOCALE_NS, "en", enDict);
777
+ return () => {
778
+ offZh();
779
+ offEn();
780
+ };
781
+ }, "dsh-plugin-better-plan: locale dictionaries");
782
+ ctx.effect(() => betterSidebar.registerTab({
783
+ id: TAB_ID,
784
+ title: () => t("tabTitle"),
785
+ icon: (size) => (0, react.createElement)(IconPlanOutline16, { size }),
786
+ order: 15,
787
+ single: true,
788
+ component: (props) => (0, react.createElement)(PlanView, props)
789
+ }), "dsh-plugin-better-plan: plan tab");
790
+ ctx.effect(() => {
791
+ let socket = null;
792
+ let retry;
793
+ let disposed = false;
794
+ let failures = 0;
795
+ let current;
796
+ const clearRetry = () => {
797
+ if (retry !== void 0) {
798
+ window.clearTimeout(retry);
799
+ retry = void 0;
800
+ }
801
+ };
802
+ const connect = (sessionId) => {
803
+ if (disposed) return;
804
+ clearRetry();
805
+ socket?.close();
806
+ const url = new URL(DELIVERY_WS_PATH, window.location.origin);
807
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
808
+ url.search = new URLSearchParams({
809
+ session: sessionId,
810
+ locale: activeLocale()
811
+ }).toString();
812
+ socket = new WebSocket(url.toString());
813
+ socket.onmessage = (event) => {
814
+ if (typeof event.data !== "string") return;
815
+ try {
816
+ applyDeliveryFrame(betterSidebar, reviewStore, JSON.parse(event.data), sessionId);
817
+ } catch {}
818
+ };
819
+ socket.onclose = () => {
820
+ if (disposed || current === void 0) return;
821
+ failures += 1;
822
+ if (failures >= FAILURE_LIMIT) {
823
+ console.error("[dsh-plugin-better-plan] delivery socket failed; stopping reconnect loop", current);
824
+ return;
825
+ }
826
+ retry = window.setTimeout(() => {
827
+ if (current !== void 0) connect(current);
828
+ }, 2e3);
829
+ };
830
+ socket.onerror = () => {
831
+ socket?.close();
832
+ };
833
+ };
834
+ const sync = () => {
835
+ const sessionId = betterSidebar.getSnapshot().sessionId;
836
+ if (sessionId === current) return;
837
+ current = sessionId;
838
+ reviewStore.reset();
839
+ failures = 0;
840
+ if (sessionId === void 0) {
841
+ clearRetry();
842
+ socket?.close();
843
+ socket = null;
844
+ return;
845
+ }
846
+ connect(sessionId);
847
+ };
848
+ sync();
849
+ const unsubscribe = betterSidebar.subscribeState(sync);
850
+ return () => {
851
+ disposed = true;
852
+ unsubscribe();
853
+ clearRetry();
854
+ socket?.close();
855
+ };
856
+ }, "dsh-plugin-better-plan: delivery socket");
857
+ }
858
+ //#endregion
859
+ exports.DELIVERY_WS_PATH = DELIVERY_WS_PATH;
860
+ exports.TAB_ID = TAB_ID;
861
+ exports.apply = apply;
862
+ exports.applyDeliveryFrame = applyDeliveryFrame;
863
+ exports.applyDeliveryPush = applyDeliveryPush;
864
+ exports.inject = inject;
865
+ return module.exports;
866
+ }
867
+ });
868
+
869
+ //# sourceMappingURL=client.js.map