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.
- package/README.md +178 -0
- package/cordis.patch.yml +18 -0
- package/dsh.plugin.json +16 -0
- package/lib/client.js +960 -0
- package/lib/client.js.map +1 -0
- package/lib/index.mjs +5620 -0
- package/lib/index.mjs.map +1 -0
- package/lib/types/client/BriefIcon.d.ts +13 -0
- package/lib/types/client/SummaryTab.d.ts +18 -0
- package/lib/types/client/TodoBoardTab.d.ts +31 -0
- package/lib/types/client/brief/board.d.ts +50 -0
- package/lib/types/client/brief/dock-shadow.d.ts +49 -0
- package/lib/types/client/brief/use-todos.d.ts +13 -0
- package/lib/types/client/file-open.d.ts +50 -0
- package/lib/types/client/index.d.ts +23 -0
- package/lib/types/client/locales.d.ts +53 -0
- package/lib/types/client/summary/DeliverablesSection.d.ts +18 -0
- package/lib/types/client/summary/deliverables.d.ts +40 -0
- package/lib/types/client/use-projection.d.ts +54 -0
- package/lib/types/index.d.ts +27 -0
- package/lib/types/projection/deliverables-fold.d.ts +85 -0
- package/lib/types/projection/keys.d.ts +25 -0
- package/lib/types/projection/register.d.ts +9 -0
- package/package.json +94 -0
- package/src/client/BriefIcon.tsx +56 -0
- package/src/client/SummaryTab.tsx +73 -0
- package/src/client/TodoBoardTab.tsx +188 -0
- package/src/client/brief/board.ts +104 -0
- package/src/client/brief/dock-shadow.tsx +74 -0
- package/src/client/brief/use-todos.ts +27 -0
- package/src/client/file-open.ts +108 -0
- package/src/client/index.tsx +144 -0
- package/src/client/locales.ts +78 -0
- package/src/client/summary/DeliverablesSection.tsx +192 -0
- package/src/client/summary/deliverables.ts +93 -0
- package/src/client/use-projection.ts +117 -0
- package/src/index.ts +32 -0
- package/src/projection/deliverables-fold.ts +206 -0
- package/src/projection/keys.ts +27 -0
- 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
|