dsh-brief-sidebar 0.3.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.
Files changed (40) hide show
  1. package/README.md +178 -0
  2. package/cordis.patch.yml +18 -0
  3. package/dsh.plugin.json +16 -0
  4. package/lib/client.js +960 -0
  5. package/lib/client.js.map +1 -0
  6. package/lib/index.mjs +5620 -0
  7. package/lib/index.mjs.map +1 -0
  8. package/lib/types/client/BriefIcon.d.ts +13 -0
  9. package/lib/types/client/SummaryTab.d.ts +18 -0
  10. package/lib/types/client/TodoBoardTab.d.ts +31 -0
  11. package/lib/types/client/brief/board.d.ts +50 -0
  12. package/lib/types/client/brief/dock-shadow.d.ts +49 -0
  13. package/lib/types/client/brief/use-todos.d.ts +13 -0
  14. package/lib/types/client/file-open.d.ts +50 -0
  15. package/lib/types/client/index.d.ts +23 -0
  16. package/lib/types/client/locales.d.ts +53 -0
  17. package/lib/types/client/summary/DeliverablesSection.d.ts +18 -0
  18. package/lib/types/client/summary/deliverables.d.ts +40 -0
  19. package/lib/types/client/use-projection.d.ts +54 -0
  20. package/lib/types/index.d.ts +27 -0
  21. package/lib/types/projection/deliverables-fold.d.ts +85 -0
  22. package/lib/types/projection/keys.d.ts +25 -0
  23. package/lib/types/projection/register.d.ts +9 -0
  24. package/package.json +94 -0
  25. package/src/client/BriefIcon.tsx +56 -0
  26. package/src/client/SummaryTab.tsx +73 -0
  27. package/src/client/TodoBoardTab.tsx +188 -0
  28. package/src/client/brief/board.ts +104 -0
  29. package/src/client/brief/dock-shadow.tsx +74 -0
  30. package/src/client/brief/use-todos.ts +27 -0
  31. package/src/client/file-open.ts +108 -0
  32. package/src/client/index.tsx +144 -0
  33. package/src/client/locales.ts +78 -0
  34. package/src/client/summary/DeliverablesSection.tsx +192 -0
  35. package/src/client/summary/deliverables.ts +93 -0
  36. package/src/client/use-projection.ts +117 -0
  37. package/src/index.ts +32 -0
  38. package/src/projection/deliverables-fold.ts +206 -0
  39. package/src/projection/keys.ts +27 -0
  40. package/src/projection/register.ts +99 -0
package/lib/client.js ADDED
@@ -0,0 +1,960 @@
1
+ window.__ModuleLoader__.load({
2
+ id: "dsh-brief-sidebar",
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 react_jsx_runtime = require("react/jsx-runtime");
9
+ //#region src/client/brief/board.ts
10
+ const STATUSES = [
11
+ "pending",
12
+ "in_progress",
13
+ "completed"
14
+ ];
15
+ /** Locale key per status. Kept here so this module owns the vocabulary. */
16
+ const STATUS_KEY = {
17
+ pending: "status.pending",
18
+ in_progress: "status.inProgress",
19
+ completed: "status.completed"
20
+ };
21
+ /** Locale key for one status label. */
22
+ function statusKey(status) {
23
+ return STATUS_KEY[status];
24
+ }
25
+ /**
26
+ * Narrow a raw projection value into a todo list.
27
+ *
28
+ * Three outcomes are kept apart on purpose, mirroring the store's own contract:
29
+ * - `undefined` — the capability is ABSENT (no session, the host unit is
30
+ * unmounted, or no baseline/frame has carried the key yet);
31
+ * - `[]` — the projection is present and the list is empty (`null` on the wire);
32
+ * - a list — real entries.
33
+ *
34
+ * A malformed entry is dropped rather than thrown on: the projection is a
35
+ * trusted Host value, but a shape drift must not blank the whole board.
36
+ *
37
+ * @param value - the raw `unknown` snapshot of the `todos` face.
38
+ * @returns the validated list, or `undefined` when the capability is absent.
39
+ */
40
+ function readTodos(value) {
41
+ if (value === null) return [];
42
+ if (!Array.isArray(value)) return void 0;
43
+ const items = [];
44
+ for (const raw of value) {
45
+ if (raw === null || typeof raw !== "object") continue;
46
+ const { content, status } = raw;
47
+ if (typeof content !== "string") continue;
48
+ if (typeof status !== "string" || !isStatus(status)) continue;
49
+ items.push({
50
+ content,
51
+ status
52
+ });
53
+ }
54
+ return items;
55
+ }
56
+ /** Whether a string is one of the three lifecycle values. */
57
+ function isStatus(value) {
58
+ return STATUSES.includes(value);
59
+ }
60
+ /** Count a todo list by status. */
61
+ function countTodos(todos) {
62
+ let completed = 0;
63
+ let inProgress = 0;
64
+ let pending = 0;
65
+ for (const item of todos) if (item.status === "completed") completed += 1;
66
+ else if (item.status === "in_progress") inProgress += 1;
67
+ else pending += 1;
68
+ return {
69
+ total: todos.length,
70
+ completed,
71
+ inProgress,
72
+ pending
73
+ };
74
+ }
75
+ /** How many entries are not finished — the tab badge. */
76
+ function unfinishedCount(todos) {
77
+ const counts = countTodos(todos);
78
+ return counts.inProgress + counts.pending;
79
+ }
80
+ /**
81
+ * The one-line progress summary, composed from parts so no interpolation
82
+ * machinery is needed (the locale binder is a plain key → string lookup).
83
+ * Mirrors the official dock's own label.
84
+ */
85
+ function progressLine(counts, t) {
86
+ const parts = [`${t("status.completed")} ${counts.completed}/${counts.total}`];
87
+ if (counts.inProgress > 0) parts.push(`${t("status.inProgress")} ${counts.inProgress}`);
88
+ if (counts.pending > 0) parts.push(`${t("status.pending")} ${counts.pending}`);
89
+ return parts.join(" · ");
90
+ }
91
+ //#endregion
92
+ //#region src/client/use-projection.ts
93
+ /**
94
+ * Reading a live session projection from the client Session Controller.
95
+ *
96
+ * better-sidebar tab bodies are NOT rendered inside a DSH slot, so the
97
+ * framework's `useProjection` standard prop is not available to them. The
98
+ * equivalent face is reachable directly: the client Session Controller is a
99
+ * cordis service on the root context, and each session binding exposes its
100
+ * projections store (`SessionFace.projections.faceOf(key)`), which is the very
101
+ * observable `useProjection` resolves. Since it is a bare
102
+ * `{ getSnapshot, subscribe }` source, `useSyncExternalStore` binds it as-is —
103
+ * no local mirror, no folding, and the value reference only changes when the
104
+ * Host pushes a frame.
105
+ *
106
+ * Everything here is resolved defensively: a host without the Session
107
+ * Controller, or a session that is not open any more, degrades to "capability
108
+ * absent" (undefined) rather than throwing inside a render.
109
+ *
110
+ * This module is key-agnostic; the per-projection knowledge (which key, how to
111
+ * narrow the raw value) lives with the feature modules that call it.
112
+ */
113
+ /** A no-op unsubscribe, kept as one identity so effects never thrash. */
114
+ const NO_SUBSCRIBE = () => {};
115
+ /** A source that never has a value — the absent-capability face. */
116
+ const ABSENT = {
117
+ getSnapshot: () => void 0,
118
+ subscribe: () => NO_SUBSCRIBE
119
+ };
120
+ /**
121
+ * Whether a value really is a projection face.
122
+ *
123
+ * The store contract promises an identity-stable face for every key, so this
124
+ * never fires against a healthy host. It exists because the fallback is worse
125
+ * than the check: `useSyncExternalStore` would throw on a face-less key, and a
126
+ * thrown render is a blank sidebar rather than a diagnosable "unavailable".
127
+ */
128
+ function isFace(value) {
129
+ if (value === null || typeof value !== "object") return false;
130
+ const face = value;
131
+ return typeof face.getSnapshot === "function" && typeof face.subscribe === "function";
132
+ }
133
+ /**
134
+ * Resolve one projection face for one session.
135
+ *
136
+ * `ctx.get('sessions')` is the safe accessor (a direct `ctx.sessions` read can
137
+ * throw on a context that does not carry the service yet).
138
+ *
139
+ * @param ctx - the client root context handed to the tab body.
140
+ * @param sessionId - the tab's session scope; undefined means "no session".
141
+ * @param key - the projection key to resolve.
142
+ * @returns the projection face, or {@link ABSENT} when unreachable.
143
+ */
144
+ function resolveFace(ctx, sessionId, key) {
145
+ if (ctx === void 0 || sessionId === void 0) return ABSENT;
146
+ const sessions = ctx.get("sessions");
147
+ if (sessions === void 0 || typeof sessions.binding !== "function") return ABSENT;
148
+ const projections = sessions.binding(sessionId)?.session?.projections;
149
+ if (projections === void 0 || typeof projections.faceOf !== "function") return ABSENT;
150
+ const face = projections.faceOf(key);
151
+ return isFace(face) ? face : ABSENT;
152
+ }
153
+ /**
154
+ * Read one frame of a projection without subscribing.
155
+ *
156
+ * The one-shot sibling of {@link useProjectionValue}, for consumers that are
157
+ * not React components — the tab badge is a plain function called on every
158
+ * tab-bar render, so it must neither hold a subscription nor allocate one.
159
+ *
160
+ * @param ctx - the client root context.
161
+ * @param sessionId - the session whose projection is read.
162
+ * @param key - the projection key to read.
163
+ * @returns the raw snapshot: `undefined` while the capability is absent; the
164
+ * semantics of any other value belong to the projection's own contract.
165
+ */
166
+ function projectionSnapshot(ctx, sessionId, key) {
167
+ return resolveFace(ctx, sessionId, key).getSnapshot();
168
+ }
169
+ /**
170
+ * Subscribe to one session projection by key.
171
+ *
172
+ * @param ctx - the client root context.
173
+ * @param sessionId - the session whose projection is shown.
174
+ * @param key - the projection key to subscribe to.
175
+ * @returns the raw snapshot value, identity-stable across frames the host did
176
+ * not change. Narrowing is the caller's job.
177
+ */
178
+ function useProjectionValue(ctx, sessionId, key) {
179
+ const face = (0, react.useMemo)(() => resolveFace(ctx, sessionId, key), [
180
+ ctx,
181
+ sessionId,
182
+ key
183
+ ]);
184
+ const subscribe = (0, react.useCallback)((listener) => face.subscribe(listener), [face]);
185
+ const getSnapshot = (0, react.useCallback)(() => face.getSnapshot(), [face]);
186
+ return (0, react.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
187
+ }
188
+ //#endregion
189
+ //#region src/client/brief/use-todos.ts
190
+ /**
191
+ * Reading the live `todos` projection — the thin, todo-specific wrapper around
192
+ * the generic face resolver (`../use-projection`). The projection key is
193
+ * registered by `dsh-tool-todo`; the host pushes finished whole values and the
194
+ * client never folds it. Narrowing lives in `./board` (`readTodos`).
195
+ */
196
+ /** The projection key registered by `dsh-tool-todo`. */
197
+ const TODOS_KEY = "todos";
198
+ /**
199
+ * Subscribe to one session's todo list.
200
+ *
201
+ * @param ctx - the client root context.
202
+ * @param sessionId - the session whose projection is shown.
203
+ * @returns the list, `[]` for a present-but-empty projection, or `undefined`
204
+ * while the capability is absent.
205
+ */
206
+ function useTodos(ctx, sessionId) {
207
+ const raw = useProjectionValue(ctx, sessionId, TODOS_KEY);
208
+ return (0, react.useMemo)(() => readTodos(raw), [raw]);
209
+ }
210
+ //#endregion
211
+ //#region src/client/TodoBoardTab.tsx
212
+ /**
213
+ * The progress section: the `todos` projection drawn as one part of the
214
+ * summary tab.
215
+ *
216
+ * The official composer-band panel is shadowed by this plugin (see
217
+ * `brief/dock-shadow.tsx`), so the projection is read here and rendered as an
218
+ * ordinary React view — no Canvas, no cross-package dependency. The full-height
219
+ * shell and the scroll container live on the summary tab (`../SummaryTab`);
220
+ * this component renders only its own slice and is the one section whose
221
+ * absent state must stay VISIBLE: the official dock is hidden while this
222
+ * plugin is mounted, so "unavailable" is a statement the reader needs.
223
+ *
224
+ * Colours come from `--dsw-alias-*` tokens only (skin contract), and the status
225
+ * palette uses tokens that actually exist in the shipping theme:
226
+ * `state-success-primary` / `state-business-primary` / `label-tertiary`.
227
+ */
228
+ /** Status → token colour for the pill's text and border. */
229
+ const STATUS_COLOR = {
230
+ completed: "var(--dsw-alias-state-success-primary)",
231
+ in_progress: "var(--dsw-alias-state-business-primary)",
232
+ pending: "var(--dsw-alias-label-tertiary)"
233
+ };
234
+ const SECTION_STYLE$1 = {
235
+ flexShrink: 0,
236
+ borderBottom: "1px solid var(--dsw-alias-border-secondary)"
237
+ };
238
+ const HEADER_STYLE$1 = {
239
+ padding: "10px 12px 0",
240
+ color: "var(--dsw-alias-label-secondary)",
241
+ fontSize: 12,
242
+ fontWeight: 600
243
+ };
244
+ const PROGRESS_STYLE = {
245
+ padding: "6px 12px 10px",
246
+ color: "var(--dsw-alias-label-secondary)",
247
+ fontSize: 12
248
+ };
249
+ const LIST_STYLE$1 = {
250
+ listStyle: "none",
251
+ margin: 0,
252
+ padding: "6px 0",
253
+ display: "flex",
254
+ flexDirection: "column"
255
+ };
256
+ const ROW_STYLE$1 = {
257
+ display: "flex",
258
+ alignItems: "flex-start",
259
+ gap: 8,
260
+ padding: "6px 12px"
261
+ };
262
+ /** Pill style, coloured per status. */
263
+ function pillStyle(status) {
264
+ return {
265
+ flexShrink: 0,
266
+ color: STATUS_COLOR[status],
267
+ border: `1px solid ${STATUS_COLOR[status]}`,
268
+ borderRadius: 999,
269
+ padding: "0 6px",
270
+ fontSize: 11,
271
+ lineHeight: "16px",
272
+ whiteSpace: "nowrap"
273
+ };
274
+ }
275
+ /** Task text: finished entries read as done, the working one as active. */
276
+ function contentStyle(status) {
277
+ return {
278
+ minWidth: 0,
279
+ overflowWrap: "anywhere",
280
+ color: status === "completed" ? "var(--dsw-alias-label-tertiary)" : status === "in_progress" ? "var(--dsw-alias-label-primary)" : "var(--dsw-alias-label-secondary)",
281
+ textDecoration: status === "completed" ? "line-through" : "none"
282
+ };
283
+ }
284
+ /** Centred message block used by every non-list state. */
285
+ function Notice(props) {
286
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
287
+ style: {
288
+ margin: "0 auto",
289
+ padding: 24,
290
+ maxWidth: 460,
291
+ textAlign: "center",
292
+ color: "var(--dsw-alias-label-tertiary)",
293
+ lineHeight: 1.7,
294
+ fontSize: 12
295
+ },
296
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
297
+ style: {
298
+ marginBottom: 6,
299
+ color: "var(--dsw-alias-label-secondary)"
300
+ },
301
+ children: props.title
302
+ }), props.detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", { children: props.detail })]
303
+ });
304
+ }
305
+ /** One task row. */
306
+ function TodoRow(props) {
307
+ const { item, t } = props;
308
+ return (0, react.createElement)("li", { style: ROW_STYLE$1 }, (0, react.createElement)("span", { style: pillStyle(item.status) }, t(statusKey(item.status))), (0, react.createElement)("span", { style: contentStyle(item.status) }, item.content));
309
+ }
310
+ /**
311
+ * The progress section. The subscription is unconditional (a plain listener,
312
+ * not IO); the three-state contract of the projection is preserved verbatim.
313
+ * @param props - translation, client context, session scope.
314
+ */
315
+ function TodoSection(props) {
316
+ const { t, ctx, scope } = props;
317
+ const todos = useTodos(ctx, scope?.sessionId);
318
+ if (todos === void 0) return (0, react.createElement)("div", {
319
+ style: SECTION_STYLE$1,
320
+ "data-dsh-brief-sidebar": "progress-section"
321
+ }, (0, react.createElement)("div", { style: HEADER_STYLE$1 }, t("section.progress")), (0, react.createElement)(Notice, {
322
+ title: t("board.unavailable"),
323
+ detail: t("board.unavailableHint")
324
+ }));
325
+ if (todos.length === 0) return (0, react.createElement)("div", {
326
+ style: SECTION_STYLE$1,
327
+ "data-dsh-brief-sidebar": "progress-section"
328
+ }, (0, react.createElement)("div", { style: HEADER_STYLE$1 }, t("section.progress")), (0, react.createElement)(Notice, {
329
+ title: t("board.empty"),
330
+ detail: t("board.emptyHint")
331
+ }));
332
+ const counts = countTodos(todos);
333
+ return (0, react.createElement)("div", {
334
+ style: SECTION_STYLE$1,
335
+ "data-dsh-brief-sidebar": "progress-section"
336
+ }, (0, react.createElement)("div", { style: HEADER_STYLE$1 }, t("section.progress")), (0, react.createElement)("div", { style: PROGRESS_STYLE }, progressLine(counts, t)), (0, react.createElement)("ul", { style: LIST_STYLE$1 }, todos.map((item, index) => (0, react.createElement)(TodoRow, {
337
+ key: `${index}:${item.content}`,
338
+ item,
339
+ t
340
+ }))));
341
+ }
342
+ //#endregion
343
+ //#region src/projection/keys.ts
344
+ /**
345
+ * The projection key this plugin contributes to the session-projection
346
+ * registry, and its wire view.
347
+ *
348
+ * The key is namespaced with the plugin name on purpose: the registry refuses
349
+ * to share a key across differing `stateVersion`s, so a collision with a
350
+ * future host-side deliverables unit would be a hard load error, not a silent
351
+ * shadow. The view type is declared structurally (the plugin has no compile
352
+ * dependency on `@deepseek-ai/dsh-session-projection`); the registry accepts
353
+ * any string key at runtime.
354
+ */
355
+ /** The projection key registered by this plugin's host half. */
356
+ const DELIVERABLES_KEY = "dshSummaryDeliverables";
357
+ //#endregion
358
+ //#region src/client/file-open.ts
359
+ /** The address scheme every file resource opens with. */
360
+ const FILE_ADDRESS_PREFIX = "dsh-resource://file/";
361
+ /** Whether a path uses a Windows drive or UNC prefix. */
362
+ function isWindowsStylePath(value) {
363
+ return /^[A-Za-z]:[/\\]/.test(value) || value.startsWith("\\\\");
364
+ }
365
+ /**
366
+ * Whether a path is absolute in either spelling the Host accepts: POSIX (`/a/b`)
367
+ * or Windows drive or UNC.
368
+ */
369
+ function isAbsoluteWorkspacePath(path) {
370
+ return path.startsWith("/") || isWindowsStylePath(path);
371
+ }
372
+ /** Component-encode one id or path segment, keeping `:` literal for drive letters. */
373
+ function encodeSegment(segment) {
374
+ return encodeURIComponent(segment).replace(/%3A/gi, ":");
375
+ }
376
+ /** Encode a `/`-separated path segment by segment. */
377
+ function encodePath(path) {
378
+ return path.split("/").map(encodeSegment).join("/");
379
+ }
380
+ /**
381
+ * Build the address of a file read through one Session.
382
+ * @param sessionId - the Session whose Host workspace resolves the path.
383
+ * @param path - absolute or workspace-relative path; backslashes are normalized
384
+ * to `/`, and leading `./` prefixes are dropped.
385
+ */
386
+ function sessionFileAddress(sessionId, path) {
387
+ const normalized = path.replace(/\\/g, "/").replace(/^(?:\.\/)+/, "");
388
+ return `${FILE_ADDRESS_PREFIX}session/${encodeSegment(sessionId)}/${encodePath(normalized)}`;
389
+ }
390
+ /**
391
+ * The address for a path as a caller holds it.
392
+ * @param sessionId - the Session the path is read in.
393
+ * @param cwd - that Session's workspace root, when known.
394
+ * @param path - absolute or workspace-relative path, in either separator spelling.
395
+ */
396
+ function fileAddressFor(sessionId, cwd, path) {
397
+ const normalized = path.replace(/\\/g, "/");
398
+ if (!isAbsoluteWorkspacePath(normalized)) return sessionFileAddress(sessionId, normalized);
399
+ const root = cwd === void 0 ? "" : cwd.replace(/\\/g, "/").replace(/\/+$/, "");
400
+ if (root !== "" && normalized === root) return sessionFileAddress(sessionId, "");
401
+ if (root !== "" && normalized.startsWith(`${root}/`)) return sessionFileAddress(sessionId, normalized.slice(root.length + 1));
402
+ return sessionFileAddress(sessionId, normalized);
403
+ }
404
+ /**
405
+ * Build a sidebar file opener for one session, or `undefined` when the
406
+ * environment cannot open previews (no `sidebarRight` service, no session).
407
+ * The cwd is re-read per call inside the opener, so a workspace switch between
408
+ * renders is picked up on the next click.
409
+ *
410
+ * @param ctx - the client root context.
411
+ * @param sessionId - the session whose workspace resolves paths.
412
+ * @returns an opener that routes a produced path to the Sidebar preview.
413
+ */
414
+ function sidebarFileOpener(ctx, sessionId) {
415
+ if (ctx === void 0 || sessionId === void 0) return void 0;
416
+ const bar = ctx.get("sidebarRight");
417
+ if (bar === void 0 || typeof bar.openResource !== "function") return void 0;
418
+ return (path) => {
419
+ const cwd = ctx.get("sessions")?.list?.getSnapshot?.().byId?.[sessionId]?.cwd;
420
+ bar.openResource(fileAddressFor(sessionId, cwd, path));
421
+ };
422
+ }
423
+ //#endregion
424
+ //#region src/client/summary/deliverables.ts
425
+ /**
426
+ * Narrow a raw projection value into a deliverables view.
427
+ *
428
+ * Two outcomes are kept apart on purpose, mirroring the store's contract:
429
+ * - `undefined` — the capability is ABSENT (the host half never registered —
430
+ * e.g. an older composition — or no frame carried the key yet);
431
+ * - a view — the projection is live. A view with `latest: null` and
432
+ * `sessionTotal: 0` IS the empty state; the fold starts publishing as soon
433
+ * as the unit registers, so "present but empty" is a real value here, not a
434
+ * sentinel.
435
+ *
436
+ * @param value - the raw `unknown` snapshot of the deliverables face.
437
+ * @returns the validated view, or `undefined` when the capability is absent.
438
+ */
439
+ function readDeliverables(value) {
440
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return void 0;
441
+ const raw = value;
442
+ if (raw.latest === void 0 || raw.sessionPaths === void 0 || raw.sessionTotal === void 0) return;
443
+ if (!isBucket(raw.latest) || !Array.isArray(raw.sessionPaths) || typeof raw.sessionTotal !== "number") return;
444
+ const paths = raw.sessionPaths.filter((path) => typeof path === "string");
445
+ return {
446
+ latest: raw.latest === null ? null : {
447
+ turn: raw.latest.turn,
448
+ paths: raw.latest.paths
449
+ },
450
+ sessionPaths: paths,
451
+ sessionTotal: raw.sessionTotal
452
+ };
453
+ }
454
+ /** Whether a value is a well-formed latest-turn bucket (or explicitly null). */
455
+ function isBucket(value) {
456
+ if (value === null) return true;
457
+ if (typeof value !== "object") return false;
458
+ const bucket = value;
459
+ return typeof bucket.turn === "number" && Array.isArray(bucket.paths);
460
+ }
461
+ /**
462
+ * The path prefix that identifies a codeplan artifact. The codeplan skill
463
+ * writes its four fixed artifacts under `$workspace\.agents\plans\<任务名>\`;
464
+ * produced paths may spell the separators either way.
465
+ */
466
+ const CODEPLAN_SEGMENT = ".agents/plans/";
467
+ /**
468
+ * Normalize path separators so one spelling of the codeplan prefix matches.
469
+ * Only classification uses this; the displayed path keeps the tool's exact
470
+ * spelling.
471
+ */
472
+ function normalized(path) {
473
+ return path.replaceAll("\\", "/");
474
+ }
475
+ /**
476
+ * Split a codeplan path into its task name and file name.
477
+ *
478
+ * @param path - a produced path; need not actually be a codeplan artifact.
479
+ * @returns task and file names, or `null` when the path is not under the
480
+ * codeplan plans directory (or names no file beneath the task folder).
481
+ */
482
+ function splitCodeplanPath(path) {
483
+ const marker = normalized(path).indexOf(CODEPLAN_SEGMENT);
484
+ if (marker < 0) return null;
485
+ const rest = normalized(path).slice(marker + 14);
486
+ const slash = rest.indexOf("/");
487
+ if (slash <= 0 || slash === rest.length - 1) return null;
488
+ return {
489
+ task: rest.slice(0, slash),
490
+ file: rest.slice(slash + 1)
491
+ };
492
+ }
493
+ /** The final path segment, under either separator. */
494
+ function basename(path) {
495
+ const normalizedPath = normalized(path);
496
+ const slash = normalizedPath.lastIndexOf("/");
497
+ return slash < 0 ? normalizedPath : normalizedPath.slice(slash + 1);
498
+ }
499
+ //#endregion
500
+ //#region src/client/locales.ts
501
+ /**
502
+ * Plugin-owned i18n dictionaries.
503
+ *
504
+ * Consumer plugins must NOT reach into better-sidebar's internal `t()` or its
505
+ * `betterSidebar` dictionary namespace, so this plugin registers its own
506
+ * namespace through the DSH `locale` service. `dsh-brief-sidebar` is outside the
507
+ * `LocaleNamespaceMap` merge table, which is exactly what the untyped
508
+ * `register(ns, dicts)` overload is for — but the dictionaries themselves stay
509
+ * strictly paired: `zh` is the key-set source of truth and `en` is typed as
510
+ * `Record<keyof typeof zh, string>`, so a missing or extra English key is a
511
+ * COMPILE error rather than a runtime fallback to the raw key.
512
+ *
513
+ * Every key referenced anywhere in `src/**` must exist in both dictionaries;
514
+ * `tests/purity.spec.ts` asserts that, so a typo in a `t('…')` call cannot
515
+ * ship as an untranslated literal.
516
+ */
517
+ /** The namespace owned by this plugin (own vocabulary, own lifecycle). */
518
+ const NS = "dsh-brief-sidebar";
519
+ /**
520
+ * Both locales in the shape the locale service consumes. Registered in ONE
521
+ * call: the registry rejects a duplicate `(namespace, locale)` pair and the
522
+ * per-locale form would leave a half-registered namespace if the second call
523
+ * threw.
524
+ */
525
+ const dictionaries = {
526
+ zh: {
527
+ "tab.title": "概要",
528
+ "tab.desc": "当前会话的任务、产物与规划文件(只读)",
529
+ "section.progress": "进展",
530
+ "section.deliverables": "产物",
531
+ "board.empty": "当前没有任务",
532
+ "board.emptyHint": "模型调用 todo_write 之后,任务会出现在这里。",
533
+ "board.unavailable": "任务暂不可用",
534
+ "board.unavailableHint": "这个会话还没有收到 todo 数据;宿主推来第一帧后会自动显示。",
535
+ "status.pending": "待处理",
536
+ "status.inProgress": "进行中",
537
+ "status.completed": "已完成",
538
+ "deliverables.empty": "本回合暂无改动文件",
539
+ "deliverables.emptyHint": "会话里成功写入或修改文件后,它们会实时出现在这里。",
540
+ "deliverables.sessionTotal": "本会话共 {count} 个文件",
541
+ "deliverable.codeplanTag": "规划"
542
+ },
543
+ en: {
544
+ "tab.title": "Summary",
545
+ "tab.desc": "Tasks, produced files and plan artifacts of this session (read-only)",
546
+ "section.progress": "Progress",
547
+ "section.deliverables": "Produced files",
548
+ "board.empty": "No tasks yet",
549
+ "board.emptyHint": "Tasks show up here once the model calls todo_write.",
550
+ "board.unavailable": "Tasks unavailable",
551
+ "board.unavailableHint": "This session has not received any todo data yet; the board fills in on the first frame.",
552
+ "status.pending": "Pending",
553
+ "status.inProgress": "In progress",
554
+ "status.completed": "Done",
555
+ "deliverables.empty": "No files changed in this turn",
556
+ "deliverables.emptyHint": "Files written or edited successfully in the session appear here live.",
557
+ "deliverables.sessionTotal": "{count} files in this session",
558
+ "deliverable.codeplanTag": "Plan"
559
+ }
560
+ };
561
+ /**
562
+ * Expand the one `{count}` placeholder the deliverables totals line uses. The
563
+ * locale binder is a plain key → string lookup (no interpolation machinery),
564
+ * mirroring how `progressLine` composes its summary from parts.
565
+ */
566
+ function expandCount(template, count) {
567
+ return template.replace("{count}", String(count));
568
+ }
569
+ //#endregion
570
+ //#region src/client/summary/DeliverablesSection.tsx
571
+ /**
572
+ * The deliverables section: the `dshSummaryDeliverables` projection drawn as
573
+ * one part of the summary tab.
574
+ *
575
+ * Unlike the progress section this is an OPTIONAL capability: where the host
576
+ * half never registered the unit (older composition, key absent), the section
577
+ * hides entirely — absence is not an error state here, and the official
578
+ * "本次产出" row in the conversation flow still covers the turn-end view.
579
+ * When the projection is live, the latest turn's produced paths update
580
+ * mid-turn: every successful mutation result publishes a frame, so the reader
581
+ * sees files appear while the turn is still running, which is exactly the gap
582
+ * the official turn-tail row has.
583
+ *
584
+ * Codeplan artifacts (paths under `.agents/plans/<任务名>/`) are ordinary
585
+ * produced entries — they are marked with a pill, not split into a separate
586
+ * section.
587
+ *
588
+ * Colours come from `--dsw-alias-*` tokens only (skin contract).
589
+ */
590
+ const SECTION_STYLE = {
591
+ flexShrink: 0,
592
+ borderBottom: "1px solid var(--dsw-alias-border-secondary)"
593
+ };
594
+ const HEADER_STYLE = {
595
+ padding: "10px 12px 0",
596
+ color: "var(--dsw-alias-label-secondary)",
597
+ fontSize: 12,
598
+ fontWeight: 600
599
+ };
600
+ const LIST_STYLE = {
601
+ listStyle: "none",
602
+ margin: 0,
603
+ padding: "6px 0",
604
+ display: "flex",
605
+ flexDirection: "column"
606
+ };
607
+ const ROW_STYLE = {
608
+ display: "flex",
609
+ alignItems: "flex-start",
610
+ gap: 8,
611
+ padding: "6px 12px"
612
+ };
613
+ const TOTAL_STYLE = {
614
+ padding: "6px 12px 10px",
615
+ color: "var(--dsw-alias-label-tertiary)",
616
+ fontSize: 12
617
+ };
618
+ const PATH_STYLE = {
619
+ minWidth: 0,
620
+ overflowWrap: "anywhere",
621
+ color: "var(--dsw-alias-label-secondary)"
622
+ };
623
+ /** Row-as-button reset: the row itself is the click target, content unchanged. */
624
+ const BUTTON_STYLE = {
625
+ ...PATH_STYLE,
626
+ background: "none",
627
+ border: "none",
628
+ font: "inherit",
629
+ fontSize: "inherit",
630
+ color: "var(--dsw-alias-label-secondary)",
631
+ padding: 0,
632
+ textAlign: "left",
633
+ cursor: "pointer"
634
+ };
635
+ /** The codeplan pill: same form as the status pills, tertiary emphasis. */
636
+ const TAG_STYLE = {
637
+ flexShrink: 0,
638
+ color: "var(--dsw-alias-label-tertiary)",
639
+ border: "1px solid var(--dsw-alias-label-tertiary)",
640
+ borderRadius: 999,
641
+ padding: "0 6px",
642
+ fontSize: 11,
643
+ lineHeight: "16px",
644
+ whiteSpace: "nowrap"
645
+ };
646
+ /**
647
+ * One produced-file row. When the sidebar opener is available the row is a
648
+ * button that routes the path to the Sidebar preview (the same channel the
649
+ * conversation's produced-file chips use); otherwise it degrades to a plain
650
+ * span. A codeplan artifact carries the pill either way.
651
+ */
652
+ function PathRow(props) {
653
+ const { path, t, open } = props;
654
+ const plan = splitCodeplanPath(path);
655
+ const title = plan === null ? path : `${t("deliverable.codeplanTag")}: ${plan.task} · ${path}`;
656
+ const label = basename(path);
657
+ const content = plan === null ? null : (0, react.createElement)("span", { style: TAG_STYLE }, t("deliverable.codeplanTag"));
658
+ if (open === void 0) return (0, react.createElement)("li", { style: ROW_STYLE }, content, (0, react.createElement)("span", {
659
+ style: PATH_STYLE,
660
+ title
661
+ }, label));
662
+ return (0, react.createElement)("li", { style: ROW_STYLE }, content, (0, react.createElement)("button", {
663
+ style: BUTTON_STYLE,
664
+ title,
665
+ onClick: () => {
666
+ open(path);
667
+ }
668
+ }, label));
669
+ }
670
+ /**
671
+ * The deliverables section. The subscription is unconditional (a plain
672
+ * listener, not IO).
673
+ * @param props - translation, client context, session scope.
674
+ */
675
+ function DeliverablesSection(props) {
676
+ const { t, ctx, scope } = props;
677
+ const view = readDeliverables(useProjectionValue(ctx, scope?.sessionId, DELIVERABLES_KEY));
678
+ if (view === void 0) return null;
679
+ const open = sidebarFileOpener(ctx, scope?.sessionId);
680
+ const latestPaths = view.latest?.paths ?? [];
681
+ if (latestPaths.length === 0 && view.sessionTotal === 0) return (0, react.createElement)("div", {
682
+ style: SECTION_STYLE,
683
+ "data-dsh-brief-sidebar": "deliverables-section"
684
+ }, (0, react.createElement)("div", { style: HEADER_STYLE }, t("section.deliverables")), (0, react.createElement)("div", { style: {
685
+ margin: "0 auto",
686
+ padding: 24,
687
+ maxWidth: 460,
688
+ textAlign: "center",
689
+ color: "var(--dsw-alias-label-tertiary)",
690
+ lineHeight: 1.7,
691
+ fontSize: 12
692
+ } }, (0, react.createElement)("div", { style: {
693
+ marginBottom: 6,
694
+ color: "var(--dsw-alias-label-secondary)"
695
+ } }, t("deliverables.empty")), (0, react.createElement)("div", null, t("deliverables.emptyHint"))));
696
+ return (0, react.createElement)("div", {
697
+ style: SECTION_STYLE,
698
+ "data-dsh-brief-sidebar": "deliverables-section"
699
+ }, (0, react.createElement)("div", { style: HEADER_STYLE }, t("section.deliverables")), (0, react.createElement)("ul", { style: LIST_STYLE }, latestPaths.map((path) => (0, react.createElement)(PathRow, {
700
+ key: path,
701
+ path,
702
+ t,
703
+ open
704
+ }))), (0, react.createElement)("div", { style: TOTAL_STYLE }, expandCount(t("deliverables.sessionTotal"), view.sessionTotal)));
705
+ }
706
+ //#endregion
707
+ //#region src/client/SummaryTab.tsx
708
+ /**
709
+ * The summary tab body: the session's at-a-glance state, composed of sections.
710
+ *
711
+ * Section order: 进展 (the todos board) → 产物 (produced files, codeplan
712
+ * artifacts marked inline) → the reserved memory-recall slot (see below).
713
+ * Each section owns its own data chain and its own three-state contract; this
714
+ * component owns only the shell.
715
+ *
716
+ * Two better-sidebar contracts are honoured:
717
+ *
718
+ * 1. **Height contract.** The tab body mounts inside a full-height column flex
719
+ * host whose `.paneBody` is a definite-height BLOCK scroll container. The
720
+ * root declares `height: 100%` + `min-height: 0`, and the scrolling element
721
+ * is an inner div — not the root. Sections are non-scrolling slices with a
722
+ * bottom border; the inner div scrolls them all.
723
+ * 2. **`visible` pause.** Nothing is drawn while the tab is not the active
724
+ * one, so a hidden tab does not re-render on every projection frame.
725
+ */
726
+ const ROOT_STYLE = {
727
+ display: "flex",
728
+ flexDirection: "column",
729
+ height: "100%",
730
+ minHeight: 0,
731
+ background: "var(--dsw-alias-bg-layer-1)",
732
+ color: "var(--dsw-alias-label-primary)",
733
+ font: "inherit",
734
+ fontSize: 13
735
+ };
736
+ const SCROLL_STYLE = {
737
+ flex: 1,
738
+ minHeight: 0,
739
+ overflow: "auto"
740
+ };
741
+ /**
742
+ * The summary tab.
743
+ * @param props - translation, client context, session scope, visibility.
744
+ */
745
+ function SummaryTab(props) {
746
+ const { t, ctx, scope, visible = true } = props;
747
+ if (!visible) return null;
748
+ return (0, react.createElement)("div", {
749
+ style: ROOT_STYLE,
750
+ "data-dsh-brief-sidebar": "board"
751
+ }, (0, react.createElement)("div", { style: SCROLL_STYLE }, (0, react.createElement)(TodoSection, {
752
+ key: "progress",
753
+ t,
754
+ ctx,
755
+ scope
756
+ }), (0, react.createElement)(DeliverablesSection, {
757
+ key: "deliverables",
758
+ t,
759
+ ctx,
760
+ scope
761
+ }), null));
762
+ }
763
+ //#endregion
764
+ //#region src/client/BriefIcon.tsx
765
+ /**
766
+ * A checklist mark: three rows with ticks and text rails — the same status
767
+ * vocabulary the board paints.
768
+ * @param size - square edge in px (the host passes its own tab-icon size).
769
+ */
770
+ function BriefIcon(size) {
771
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
772
+ width: size,
773
+ height: size,
774
+ viewBox: "0 0 16 16",
775
+ fill: "none",
776
+ "aria-hidden": "true",
777
+ focusable: "false",
778
+ style: {
779
+ display: "block",
780
+ flex: "none"
781
+ },
782
+ children: [
783
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
784
+ x: 1.6,
785
+ y: 2.4,
786
+ width: 12.8,
787
+ height: 11.2,
788
+ rx: 2,
789
+ stroke: "var(--dsw-alias-label-tertiary)",
790
+ strokeWidth: 1.2
791
+ }),
792
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
793
+ d: "M4.2 6.1l1 1 1.8-2",
794
+ stroke: "var(--dsw-alias-state-success-primary)",
795
+ strokeWidth: 1.3,
796
+ strokeLinecap: "round",
797
+ strokeLinejoin: "round"
798
+ }),
799
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
800
+ d: "M4.2 10.6l1 1 1.8-2",
801
+ stroke: "var(--dsw-alias-label-tertiary)",
802
+ strokeWidth: 1.3,
803
+ strokeLinecap: "round",
804
+ strokeLinejoin: "round"
805
+ }),
806
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", {
807
+ d: "M8.4 6.4h4M8.4 10.9h4",
808
+ stroke: "var(--dsw-alias-label-tertiary)",
809
+ strokeWidth: 1.2,
810
+ strokeLinecap: "round"
811
+ })
812
+ ]
813
+ });
814
+ }
815
+ //#endregion
816
+ //#region src/client/brief/dock-shadow.tsx
817
+ /** The built-in cell id of the official todo dock. */
818
+ const TODO_CELL_ID = "todo";
819
+ /** The replacement entry: deliberately empty. */
820
+ function TodoDockShadow() {
821
+ return null;
822
+ }
823
+ /**
824
+ * Register the shadowing entry.
825
+ *
826
+ * The caller wraps this in `ctx.effect(...)` so the returned disposer rides the
827
+ * plugin fiber: unloading the plugin restores the official dock.
828
+ *
829
+ * @param ctx - the client root context.
830
+ * @returns the disposer, or undefined when the slots service is unreachable.
831
+ */
832
+ function registerTodoDockShadow(ctx) {
833
+ const slots = ctx.get("slots");
834
+ if (slots === void 0 || typeof slots.inject !== "function") return void 0;
835
+ return slots.inject("conversation.input.dock", () => slots.register({
836
+ name: "conversation.input.dock",
837
+ id: TODO_CELL_ID,
838
+ order: 0,
839
+ priority: -1,
840
+ registrant: "dsh-brief-sidebar"
841
+ }, TodoDockShadow));
842
+ }
843
+ //#endregion
844
+ //#region src/client/index.tsx
845
+ /**
846
+ * Browser half: the task board tab, plus the shadow that hides the official
847
+ * composer-band todo panel.
848
+ *
849
+ * Registration order matters and is deliberate:
850
+ *
851
+ * 1. **Dictionaries first.** Every later registration can render translated
852
+ * copy, and a dictionary that lands after the first paint would show raw
853
+ * keys for a frame.
854
+ * 2. **Soft-dependency check.** `inject` already waits for `betterSidebar`, so
855
+ * the guard only fires in a composition that relaxes the inject list. It is
856
+ * kept because it is what stops the plugin from hiding the official panel
857
+ * when it has no board to replace it with.
858
+ * 3. **Shadow the official dock.** Deliberately BEFORE the tab registration:
859
+ * the panel is the only other carrier of the list, so the replacement has to
860
+ * exist in the same activation.
861
+ * 4. **Register the tab.**
862
+ *
863
+ * Every registration rides `ctx.effect(fn, label)` so its disposer is revoked on
864
+ * fiber teardown (HMR / disable), which is also what makes the shadow
865
+ * reversible: unload the plugin and the official dock renders again.
866
+ *
867
+ * Contract notes for the two surfaces touched here:
868
+ * - better-sidebar tabs are hosted by DSH's native right Sidebar, so the tab
869
+ * body receives `TabComponentProps` (`ctx` + `scope` + `visible`) and NOT the
870
+ * framework's session-scoped slot props.
871
+ * - `conversation.input.dock` is the DSH slot the official todo panel occupies;
872
+ * see `brief/dock-shadow.tsx` for why a lower priority wins the cell.
873
+ */
874
+ /**
875
+ * Tab type id. Package-prefixed so it cannot collide with a built-in type.
876
+ * Unchanged since the 0.1.0 board tab: the summary tab REPLACES the board tab
877
+ * in place, so an opened or pinned tab survives the upgrade.
878
+ */
879
+ const TAB_ID = "dsh-brief-sidebar:board";
880
+ /**
881
+ * Position in the host's new-tab guide.
882
+ *
883
+ * Built-ins on this host generation sit at editor 10 / git 20 / subagent 30 /
884
+ * sidechat 35 / terminal 40 / browser 50. 15 places a per-session task board
885
+ * right after the editor, which is where a reader looks for session state.
886
+ */
887
+ const TAB_ORDER = 15;
888
+ /** Services required before `apply` runs. */
889
+ const inject = [
890
+ "betterSidebar",
891
+ "locale",
892
+ "slots"
893
+ ];
894
+ /**
895
+ * An effect body must return a disposer (cordis rejects `undefined` with a
896
+ * `TypeError`), so a path that could not register anything returns this no-op
897
+ * instead of nothing.
898
+ */
899
+ const NOOP = () => {};
900
+ /**
901
+ * Browser-face apply.
902
+ *
903
+ * @param ctx - the client root context.
904
+ */
905
+ function apply(ctx) {
906
+ const locale = ctx.get("locale");
907
+ if (locale !== void 0) ctx.effect(() => locale.register(NS, dictionaries), "dsh-brief-sidebar: dictionaries");
908
+ /**
909
+ * Translate one of our keys. `bind` returns a cached function that reads the
910
+ * ACTIVE locale at call time, so a language switch needs no re-apply; an
911
+ * unknown namespace or key falls back to the key itself rather than throwing
912
+ * inside a render.
913
+ */
914
+ const t = (key) => {
915
+ if (locale === void 0) return key;
916
+ try {
917
+ return locale.bind("dsh-brief-sidebar")(key) || key;
918
+ } catch {
919
+ return key;
920
+ }
921
+ };
922
+ const bar = ctx.get("betterSidebar");
923
+ if (bar === void 0) return;
924
+ ctx.effect(() => registerTodoDockShadow(ctx) ?? NOOP, "dsh-brief-sidebar: dock shadow");
925
+ ctx.effect(() => bar.registerTab({
926
+ id: TAB_ID,
927
+ title: () => t("tab.title"),
928
+ description: () => t("tab.desc"),
929
+ icon: (size) => BriefIcon(size),
930
+ order: 15,
931
+ single: true,
932
+ /**
933
+ * Unfinished count on the tab strip. Called on every tab-bar render, so
934
+ * it reads one snapshot and allocates no subscription; a completed list
935
+ * shows no badge at all.
936
+ */
937
+ badge: (badgeCtx, badgeScope) => {
938
+ const todos = readTodos(projectionSnapshot(badgeCtx, badgeScope.sessionId, TODOS_KEY));
939
+ if (todos === void 0) return null;
940
+ const pending = unfinishedCount(todos);
941
+ return pending > 0 ? pending : null;
942
+ },
943
+ component: (props) => (0, react.createElement)(SummaryTab, {
944
+ t,
945
+ ctx: props.ctx,
946
+ scope: props.scope,
947
+ visible: props.visible
948
+ })
949
+ }), "dsh-brief-sidebar: tab");
950
+ }
951
+ //#endregion
952
+ exports.TAB_ID = TAB_ID;
953
+ exports.TAB_ORDER = TAB_ORDER;
954
+ exports.apply = apply;
955
+ exports.inject = inject;
956
+ return module.exports;
957
+ }
958
+ });
959
+
960
+ //# sourceMappingURL=client.js.map