dsh-report-ledger 0.1.0 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +500 -55
- package/THIRD-PARTY-NOTICES.md +9 -8
- package/lib/client.js +3560 -690
- package/lib/client.js.map +1 -1
- package/lib/index.js +2717 -7025
- package/package.json +17 -7
package/lib/client.js
CHANGED
|
@@ -5,9 +5,39 @@ window.__ModuleLoader__.load({
|
|
|
5
5
|
var exports = module.exports;
|
|
6
6
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
7
7
|
let react = require("react");
|
|
8
|
+
let _deepseek_ai_dsh_client_ui_primitives = require("@deepseek-ai/dsh-client-ui-primitives");
|
|
8
9
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
9
10
|
//#region src/client/timeline-model.ts
|
|
10
11
|
/**
|
|
12
|
+
* The timeline's ordering: by time, and at the same instant a session before a
|
|
13
|
+
* report.
|
|
14
|
+
*
|
|
15
|
+
* Extracted so that INSERTING a row uses the same rule as SORTING rows — two
|
|
16
|
+
* copies would drift, and the synthesized row would land somewhere the sort
|
|
17
|
+
* disagrees with.
|
|
18
|
+
* @param left - one row.
|
|
19
|
+
* @param right - the other row.
|
|
20
|
+
* @returns the comparison result.
|
|
21
|
+
*/
|
|
22
|
+
function byTime(left, right) {
|
|
23
|
+
return left.at - right.at || (left.kind === right.kind ? 0 : left.kind === "session" ? -1 : 1);
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The depth of every session in one payload, keyed by session id.
|
|
27
|
+
*
|
|
28
|
+
* Exported because the views need the same map for a row they synthesize rather
|
|
29
|
+
* than build: the open report keeps the indentation of the session that authored
|
|
30
|
+
* it, and asking the model for the map is what stops a second copy of "depth
|
|
31
|
+
* means the author's depth" from growing in the view.
|
|
32
|
+
* @param payload - the endpoint payload.
|
|
33
|
+
* @returns session id to depth.
|
|
34
|
+
*/
|
|
35
|
+
function sessionDepths(payload) {
|
|
36
|
+
const depth = /* @__PURE__ */ new Map();
|
|
37
|
+
for (const node of payload.sessions) depth.set(node.id, node.depth);
|
|
38
|
+
return depth;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
11
41
|
* Build the merged, time-ordered row list.
|
|
12
42
|
*
|
|
13
43
|
* Sessions are placed at their creation time and reports at theirs, so the list
|
|
@@ -15,34 +45,110 @@ window.__ModuleLoader__.load({
|
|
|
15
45
|
* branched, a report where it was opened. Report depth comes from the session
|
|
16
46
|
* that authored it, which is what keeps the indentation meaningful even when the
|
|
17
47
|
* session row itself is filtered out.
|
|
48
|
+
*
|
|
49
|
+
* A report row's own `at` is also the timestamp its row prints, so the list can
|
|
50
|
+
* never be ordered by one axis and read as sorted by another.
|
|
18
51
|
* @param payload - the endpoint payload.
|
|
19
52
|
* @param basis - whether to place a report at its creation or its last activity.
|
|
20
53
|
* @returns the rows, oldest first.
|
|
21
54
|
*/
|
|
22
55
|
function buildRows(payload, basis = "created") {
|
|
23
|
-
const depth =
|
|
56
|
+
const depth = sessionDepths(payload);
|
|
24
57
|
const rows = [];
|
|
25
|
-
for (const node of payload.sessions) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
...node.title === void 0 ? {} : { title: node.title }
|
|
35
|
-
});
|
|
36
|
-
}
|
|
58
|
+
for (const node of payload.sessions) rows.push({
|
|
59
|
+
kind: "session",
|
|
60
|
+
at: node.createdAt ?? 0,
|
|
61
|
+
id: node.id,
|
|
62
|
+
depth: node.depth,
|
|
63
|
+
delegated: node.delegated,
|
|
64
|
+
live: node.live,
|
|
65
|
+
...node.title === void 0 ? {} : { title: node.title }
|
|
66
|
+
});
|
|
37
67
|
for (const front of payload.reports) rows.push({
|
|
38
68
|
kind: "report",
|
|
39
69
|
at: basis === "created" ? front.created : front.updated,
|
|
40
70
|
depth: depth.get(front.from) ?? 0,
|
|
41
71
|
front
|
|
42
72
|
});
|
|
43
|
-
rows.sort(
|
|
73
|
+
rows.sort(byTime);
|
|
44
74
|
return rows;
|
|
45
75
|
}
|
|
76
|
+
/**
|
|
77
|
+
* Shorten a session id so a digest row can be read.
|
|
78
|
+
*
|
|
79
|
+
* A session id is 36 characters and a report's recipients are a list of them, so
|
|
80
|
+
* an unabbreviated meta line is mostly uuid and carries almost no information a
|
|
81
|
+
* human can use. Eight characters of the leading segment stay unique within one
|
|
82
|
+
* collaboration and, unlike a session title, are bounded: titles are the first
|
|
83
|
+
* prompt of a session ("You are doing READ-ONLY research"), so swapping ids for
|
|
84
|
+
* titles trades one unreadable line for another.
|
|
85
|
+
*
|
|
86
|
+
* This is a DENSE-ROW display only. The detail panel keeps full ids, because
|
|
87
|
+
* that is the view somebody copies an id out of, and the tooltip on every
|
|
88
|
+
* shortened id carries the full value.
|
|
89
|
+
* @param id - the session id.
|
|
90
|
+
* @param length - how many characters to keep.
|
|
91
|
+
* @returns the shortened id.
|
|
92
|
+
*/
|
|
93
|
+
function shortId(id, length = 8) {
|
|
94
|
+
const bare = id.startsWith("session-") ? id.slice(8) : id;
|
|
95
|
+
return bare.length <= length ? bare : bare.slice(0, length);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Display titles for the sessions in one payload, keyed by session id.
|
|
99
|
+
*
|
|
100
|
+
* Titles are resolved only for the subtree, so an id from another tree simply
|
|
101
|
+
* has none — which is why every caller must fall back to the shortened id
|
|
102
|
+
* rather than assuming a name exists.
|
|
103
|
+
* @param payload - the endpoint payload.
|
|
104
|
+
* @returns the titles that were resolved.
|
|
105
|
+
*/
|
|
106
|
+
function sessionTitles(payload) {
|
|
107
|
+
const titles = /* @__PURE__ */ new Map();
|
|
108
|
+
for (const node of payload.sessions) if (typeof node.title === "string" && node.title !== "") titles.set(node.id, node.title);
|
|
109
|
+
return titles;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Make sure the open report has a row, and put it where it belongs.
|
|
113
|
+
*
|
|
114
|
+
* The detail panel is anchored to a report row, so the open report must HAVE
|
|
115
|
+
* one. Two real user paths would otherwise leave the panel with nowhere to
|
|
116
|
+
* render: a thread link pointing at a report outside this tree's payload, and
|
|
117
|
+
* any filter applied while a report is open. In the first case the panel is also
|
|
118
|
+
* the only place the "not in this tree" notice can appear, so the panel is not
|
|
119
|
+
* hoisted out of the list — a row is synthesized instead.
|
|
120
|
+
*
|
|
121
|
+
* The synthesized row is INSERTED at its own place on the time axis rather than
|
|
122
|
+
* appended. Appending made a report created at 11:12 appear below one created at
|
|
123
|
+
* 14:30, which reads as a broken sort at exactly the moment the user is trying
|
|
124
|
+
* to understand why something is missing.
|
|
125
|
+
*
|
|
126
|
+
* The depth is passed in rather than assumed: the caller has the payload and can
|
|
127
|
+
* ask `sessionDepths` for the author's depth, and a row that loses its
|
|
128
|
+
* indentation when a filter hides it would be a second, quieter broken sort.
|
|
129
|
+
* Reports whose author is outside the subtree are at the root (depth 0).
|
|
130
|
+
* @param kept - the rows that survived the filter.
|
|
131
|
+
* @param front - the digest of the open report.
|
|
132
|
+
* @param basis - the active time basis, so the row moves with the rest.
|
|
133
|
+
* @param depth - the author's depth from the payload, or 0 outside the subtree.
|
|
134
|
+
* @returns the rows, with the open report present exactly once.
|
|
135
|
+
*/
|
|
136
|
+
function withSynthesizedRow(kept, front, basis, depth) {
|
|
137
|
+
if (kept.some((row) => row.kind === "report" && row.front.report === front.report)) return [...kept];
|
|
138
|
+
const row = {
|
|
139
|
+
kind: "report",
|
|
140
|
+
at: basis === "created" ? front.created : front.updated,
|
|
141
|
+
depth,
|
|
142
|
+
front
|
|
143
|
+
};
|
|
144
|
+
const index = kept.findIndex((candidate) => byTime(row, candidate) < 0);
|
|
145
|
+
if (index < 0) return [...kept, row];
|
|
146
|
+
return [
|
|
147
|
+
...kept.slice(0, index),
|
|
148
|
+
row,
|
|
149
|
+
...kept.slice(index)
|
|
150
|
+
];
|
|
151
|
+
}
|
|
46
152
|
/** Every string a report row can be found by. */
|
|
47
153
|
function reportHaystack(front) {
|
|
48
154
|
return [
|
|
@@ -54,7 +160,9 @@ window.__ModuleLoader__.load({
|
|
|
54
160
|
...front.cc,
|
|
55
161
|
...front.authors,
|
|
56
162
|
front.task ?? "",
|
|
57
|
-
...front.artifacts
|
|
163
|
+
...front.artifacts,
|
|
164
|
+
front.worktree?.branch ?? "",
|
|
165
|
+
front.worktree?.path ?? ""
|
|
58
166
|
].join("\n").toLowerCase();
|
|
59
167
|
}
|
|
60
168
|
/** Every string a session row can be found by. */
|
|
@@ -113,6 +221,51 @@ window.__ModuleLoader__.load({
|
|
|
113
221
|
return filter.status !== "all" || filter.text.trim() !== "";
|
|
114
222
|
}
|
|
115
223
|
/**
|
|
224
|
+
* Index the payload's working-tree lanes by report id.
|
|
225
|
+
*
|
|
226
|
+
* The lanes carry the lease state, which the digests do not: liveness is derived
|
|
227
|
+
* from the hop stream, and the host derives it once rather than each half of the
|
|
228
|
+
* plugin deriving it separately.
|
|
229
|
+
* @param payload - the endpoint payload.
|
|
230
|
+
* @returns report id to lane.
|
|
231
|
+
*/
|
|
232
|
+
function laneIndex(payload) {
|
|
233
|
+
return new Map(payload.worktrees.map((lane) => [lane.report, lane]));
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* How a lease state should read at a glance.
|
|
237
|
+
*
|
|
238
|
+
* A live claim is the only state that means someone is working *now*, so it is
|
|
239
|
+
* the only one that earns a positive tone. A cleared checkout is deliberately
|
|
240
|
+
* plain rather than alarming: the ledger tidying up after itself is not a
|
|
241
|
+
* problem to be surfaced, and giving it a warning tone would teach a reader to
|
|
242
|
+
* ignore warnings.
|
|
243
|
+
*/
|
|
244
|
+
const LEASE_TONE = {
|
|
245
|
+
held: "success",
|
|
246
|
+
released: "info",
|
|
247
|
+
reclaimed: "neutral",
|
|
248
|
+
none: "neutral"
|
|
249
|
+
};
|
|
250
|
+
/** The last path segment, for naming a checkout that has no branch. */
|
|
251
|
+
function lastSegment(path) {
|
|
252
|
+
const parts = path.split(/[\\/]/);
|
|
253
|
+
return parts[parts.length - 1] || path;
|
|
254
|
+
}
|
|
255
|
+
/**
|
|
256
|
+
* The short name a checkout is shown under.
|
|
257
|
+
*
|
|
258
|
+
* Taken from the binding rather than from the lane, because a report opened by
|
|
259
|
+
* id can be rendered before it is in the payload's lane list — the binding is
|
|
260
|
+
* always on the row, the lane is not.
|
|
261
|
+
* @param binding - the row's binding, when it has one.
|
|
262
|
+
* @returns the label, or `undefined` when the report is not bound.
|
|
263
|
+
*/
|
|
264
|
+
function worktreeLabel(binding) {
|
|
265
|
+
if (binding === void 0) return void 0;
|
|
266
|
+
return binding.branch ?? lastSegment(binding.path);
|
|
267
|
+
}
|
|
268
|
+
/**
|
|
116
269
|
* Count how many report rows are visible under a filter.
|
|
117
270
|
* @param rows - every assembled row.
|
|
118
271
|
* @param filter - the active filter.
|
|
@@ -124,6 +277,32 @@ window.__ModuleLoader__.load({
|
|
|
124
277
|
return count;
|
|
125
278
|
}
|
|
126
279
|
/**
|
|
280
|
+
* Decide which empty-state message applies.
|
|
281
|
+
*
|
|
282
|
+
* An empty ledger and a filter that matched nothing look identical on screen but
|
|
283
|
+
* mean opposite things: one says "nothing has happened in this tree yet", the
|
|
284
|
+
* other says "you are looking at a subset of what happened". Saying the wrong
|
|
285
|
+
* one makes a working ledger look broken, so the choice is made here — where it
|
|
286
|
+
* is pinned by tests — rather than inline in the view.
|
|
287
|
+
*
|
|
288
|
+
* Counted on REPORT rows only. Session rows always survive a status chip, so
|
|
289
|
+
* counting them would let a tree full of sessions report itself as "you have
|
|
290
|
+
* filtered everything out" while the actual reason is that no report exists.
|
|
291
|
+
*
|
|
292
|
+
* The `no-reports` case deliberately wins over the filter: when the ledger holds
|
|
293
|
+
* nothing, a filter cannot be the reason nothing is on screen, and blaming it
|
|
294
|
+
* would send the reader looking for a filter to clear that they never set.
|
|
295
|
+
* @param payload - the endpoint payload.
|
|
296
|
+
* @param rows - every assembled row.
|
|
297
|
+
* @param filter - the active filter.
|
|
298
|
+
* @returns which message the view should show, if any.
|
|
299
|
+
*/
|
|
300
|
+
function emptyState(payload, rows, filter) {
|
|
301
|
+
if (payload.reports.length === 0) return "no-reports";
|
|
302
|
+
if (!isFiltering(filter)) return "none";
|
|
303
|
+
return visibleReports(rows, filter) === 0 ? "filtered-out" : "none";
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
127
306
|
* The report ids present in one payload.
|
|
128
307
|
*
|
|
129
308
|
* A thread link can point outside the subtree — a report answering one opened by
|
|
@@ -164,725 +343,3414 @@ window.__ModuleLoader__.load({
|
|
|
164
343
|
truncated: true
|
|
165
344
|
};
|
|
166
345
|
}
|
|
167
|
-
//#endregion
|
|
168
|
-
//#region src/client/ReportsView.tsx
|
|
169
346
|
/**
|
|
170
|
-
*
|
|
347
|
+
* Close a code fence left hanging by truncation.
|
|
171
348
|
*
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
349
|
+
* Cutting Markdown at a character count is not the same operation as cutting
|
|
350
|
+
* plain text: the result can be a document that does not parse as written. The
|
|
351
|
+
* one construct that changes the meaning of everything after it is a code fence,
|
|
352
|
+
* so a body cut inside a fenced block hands the renderer an unterminated one and
|
|
353
|
+
* leaves it to decide where the block ends.
|
|
176
354
|
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
355
|
+
* The renderer here does cope (measured: a body cut mid-fence renders as one
|
|
356
|
+
* closed code block with no trailing artifact), which is exactly why this is
|
|
357
|
+
* cheap insurance rather than a fix for something visibly broken: the renderer is
|
|
358
|
+
* a shell platform module this plugin does not own, and its leniency is not a
|
|
359
|
+
* contract. Balancing the document costs one line and makes the plugin's output
|
|
360
|
+
* valid Markdown no matter who renders it.
|
|
181
361
|
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
362
|
+
* Balanced text is returned untouched, so this only ever adds the fence the cut
|
|
363
|
+
* removed. Only backtick fences are counted; `~~~` fences are rare enough in
|
|
364
|
+
* report bodies that guessing which convention the author used would be worse
|
|
365
|
+
* than leaving them alone.
|
|
184
366
|
*
|
|
185
|
-
*
|
|
367
|
+
* A fence is a RUN, not a token: CommonMark lets an opener use four backticks
|
|
368
|
+
* precisely so the body can contain a literal three-backtick line, and requires
|
|
369
|
+
* the closer to be at least as long as the opener. Counting every line that
|
|
370
|
+
* merely starts with three backticks therefore gets exactly the documents this
|
|
371
|
+
* function exists for backwards — the nested block would look balanced while its
|
|
372
|
+
* real (longer) fence stayed open. So the opener's length is remembered and only
|
|
373
|
+
* a same-character run of at least that length closes it.
|
|
374
|
+
* @param text - the already-truncated head.
|
|
375
|
+
* @returns the head, with an unclosed fence closed.
|
|
186
376
|
*/
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
try {
|
|
197
|
-
body = await response.json();
|
|
198
|
-
} catch {
|
|
199
|
-
throw new Error(`HTTP ${response.status}`);
|
|
377
|
+
function closeOpenFence(text) {
|
|
378
|
+
let open;
|
|
379
|
+
for (const line of text.split("\n")) {
|
|
380
|
+
const stripped = line.trimStart();
|
|
381
|
+
if (!stripped.startsWith("```")) continue;
|
|
382
|
+
let run = 0;
|
|
383
|
+
while (stripped[run] === "`") run += 1;
|
|
384
|
+
if (open === void 0) open = run;
|
|
385
|
+
else if (run >= open) open = void 0;
|
|
200
386
|
}
|
|
201
|
-
|
|
202
|
-
if (envelope.ok !== true) throw new Error(envelope.code ?? `HTTP ${response.status}`);
|
|
203
|
-
return envelope.value;
|
|
387
|
+
return open === void 0 ? text : `${text}\n${"`".repeat(open)}`;
|
|
204
388
|
}
|
|
205
|
-
|
|
389
|
+
//#endregion
|
|
390
|
+
//#region src/client/format.ts
|
|
391
|
+
/**
|
|
392
|
+
* The two text helpers both report surfaces need.
|
|
393
|
+
*
|
|
394
|
+
* Tiny and pure on purpose: `fill` is the `{name}` substitution every dictionary
|
|
395
|
+
* template is written in, and `stamp` is the one timestamp format the ledger
|
|
396
|
+
* uses. They live outside the views because there are now two of them — the
|
|
397
|
+
* conversation tab and the right column's canvas tab — and a second copy of
|
|
398
|
+
* either would be a second answer to the same question.
|
|
399
|
+
*
|
|
400
|
+
* @module dsh-report-ledger/client/format
|
|
401
|
+
*/
|
|
402
|
+
/** Substitute `{name}` placeholders, leaving unknown names alone. */
|
|
206
403
|
function fill(template, params) {
|
|
207
404
|
return template.replace(/\{(\w+)\}/g, (whole, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : whole);
|
|
208
405
|
}
|
|
209
|
-
/** Format one timestamp for a timeline row. */
|
|
406
|
+
/** Format one timestamp for a timeline row or a hop. */
|
|
210
407
|
function stamp(at) {
|
|
211
408
|
if (at === void 0 || at <= 0) return "";
|
|
212
409
|
return new Date(at).toLocaleString();
|
|
213
410
|
}
|
|
214
|
-
/**
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
411
|
+
/** How long the path's copy control shows its "copied" state. */
|
|
412
|
+
const COPIED_HOLD_MS = 1200;
|
|
413
|
+
//#endregion
|
|
414
|
+
//#region src/client/locales.ts
|
|
415
|
+
/** Chinese dictionary — the source of truth for the key set. */
|
|
416
|
+
const zh = {
|
|
417
|
+
"view.tab": "汇报",
|
|
418
|
+
"view.loading": "正在读取账本…",
|
|
419
|
+
"view.summary": "{sessions} 个会话 · {reports} 份汇报",
|
|
420
|
+
"view.empty": "这个会话树下还没有汇报。代理用 report_author 开一份汇报后,这里会出现它的缩略卡。",
|
|
421
|
+
"view.refresh": "刷新",
|
|
422
|
+
"view.retry": "重试",
|
|
423
|
+
"view.error": "读取失败:{code}",
|
|
424
|
+
"view.live": "在线",
|
|
425
|
+
"view.delegated": "子代理",
|
|
426
|
+
"view.detail.loading": "正在读取传递路径…",
|
|
427
|
+
"view.detail.route": "传递路径",
|
|
428
|
+
"view.detail.routeEmpty": "(还没有记录跳)",
|
|
429
|
+
"view.detail.pending": "仍在等待送达:{targets}",
|
|
430
|
+
"view.detail.body": "正文",
|
|
431
|
+
"view.detail.path": "账本文件",
|
|
432
|
+
"view.copyPath": "复制账本路径",
|
|
433
|
+
"row.hops": "{count} 跳",
|
|
434
|
+
"row.authors": "{count} 位共写",
|
|
435
|
+
"row.filterByTask": "只看任务「{task}」",
|
|
436
|
+
"row.filterByWorktree": "只看检出「{worktree}」",
|
|
437
|
+
"row.leaseHeld": "工作区:正在被这份汇报占用",
|
|
438
|
+
"row.leaseReleased": "工作区:占用已结束,检出仍留在磁盘上",
|
|
439
|
+
"row.leaseReclaimed": "工作区:已被账本回收清除",
|
|
440
|
+
"row.leaseNone": "工作区:绑定已记录,但从未被占用",
|
|
441
|
+
"row.expand": "展开 {report}",
|
|
442
|
+
"row.collapse": "收起 {report}",
|
|
443
|
+
"row.last": "最后",
|
|
444
|
+
"hop.authored": "发起",
|
|
445
|
+
"hop.contributed": "共写",
|
|
446
|
+
"hop.sent": "主送",
|
|
447
|
+
"hop.delivered": "送达",
|
|
448
|
+
"hop.cc": "抄送",
|
|
449
|
+
"hop.forwarded": "转呈",
|
|
450
|
+
"hop.copied": "副本",
|
|
451
|
+
"hop.read": "阅读",
|
|
452
|
+
"hop.acked": "回执",
|
|
453
|
+
"hop.leased": "认领工作区",
|
|
454
|
+
"hop.released": "释放工作区",
|
|
455
|
+
"hop.reclaimed": "回收工作区",
|
|
456
|
+
"hop.amended": "修正",
|
|
457
|
+
"hop.closed": "结案",
|
|
458
|
+
"hop.reopened": "重开",
|
|
459
|
+
"filter.all": "全部",
|
|
460
|
+
"filter.open": "进行中",
|
|
461
|
+
"filter.acked": "已回执",
|
|
462
|
+
"filter.closed": "已结案",
|
|
463
|
+
"filter.search": "搜索主题、编号、会话或收发…",
|
|
464
|
+
"filter.clear": "清除",
|
|
465
|
+
"filter.showing": "显示 {visible}/{total} 份汇报",
|
|
466
|
+
"filter.none": "没有符合当前筛选的汇报。",
|
|
467
|
+
"filter.hint": "状态筛选只作用于汇报;搜索会同时匹配会话标题与编号,以及任务标签和检出名。",
|
|
468
|
+
"basis.created": "按创建",
|
|
469
|
+
"basis.updated": "按最近活动",
|
|
470
|
+
"basis.hint": "汇报按创建时间还是按最后一次活动排入时间线;会话始终按创建时间。",
|
|
471
|
+
"detail.thread": "线程",
|
|
472
|
+
"detail.parent": "上溯",
|
|
473
|
+
"detail.children": "下递",
|
|
474
|
+
"detail.openLinked": "打开 {report}",
|
|
475
|
+
"detail.outside": "{report} 不在当前会话树的列表里(它可能属于另一条线);正文与路径仍可读取。",
|
|
476
|
+
"detail.filteredOut": "{report} 在当前筛选下被隐藏了(用「清除」恢复)。",
|
|
477
|
+
"detail.backToList": "回到列表",
|
|
478
|
+
"detail.bodyTruncated": "正文过长,此处只显示开头 {chars} 字;完整内容见账本文件。",
|
|
479
|
+
"cardgraph.external": "树外",
|
|
480
|
+
"cardgraph.zoomIn": "放大",
|
|
481
|
+
"cardgraph.zoomOut": "缩小",
|
|
482
|
+
"cardgraph.zoom": "{percent}%",
|
|
483
|
+
"cardgraph.fit": "填满宽度",
|
|
484
|
+
"cardgraph.summary": "{frames} 个框 · {cards} 份汇报",
|
|
485
|
+
"cardgraph.empty": "没有汇报",
|
|
486
|
+
"cardgraph.gestureHint": "滚轮平移 · Ctrl+滚轮缩放",
|
|
487
|
+
"sidebar.guideTitle": "汇报画布",
|
|
488
|
+
"sidebar.guideDescription": "把拓扑画布停靠进右侧栏",
|
|
489
|
+
"sidebar.chip": "汇报画布",
|
|
490
|
+
"body.copy": "复制",
|
|
491
|
+
"body.copied": "已复制",
|
|
492
|
+
"body.footnotes": "脚注"
|
|
493
|
+
};
|
|
494
|
+
/** English dictionary, checked against the Chinese key set. */
|
|
495
|
+
const en = {
|
|
496
|
+
"view.tab": "Reports",
|
|
497
|
+
"view.loading": "Reading the ledger…",
|
|
498
|
+
"view.summary": "{sessions} sessions · {reports} reports",
|
|
499
|
+
"view.empty": "No reports in this session tree yet. Once an agent opens one with report_author, its digest card appears here.",
|
|
500
|
+
"view.refresh": "Refresh",
|
|
501
|
+
"view.retry": "Retry",
|
|
502
|
+
"view.error": "Read failed: {code}",
|
|
503
|
+
"view.live": "live",
|
|
504
|
+
"view.delegated": "subagent",
|
|
505
|
+
"view.detail.loading": "Reading the transfer path…",
|
|
506
|
+
"view.detail.route": "Transfer path",
|
|
507
|
+
"view.detail.routeEmpty": "(no hops recorded yet)",
|
|
508
|
+
"view.detail.pending": "Still owed to: {targets}",
|
|
509
|
+
"view.detail.body": "Body",
|
|
510
|
+
"view.detail.path": "Ledger file",
|
|
511
|
+
"view.copyPath": "Copy the ledger path",
|
|
512
|
+
"row.hops": "{count} hops",
|
|
513
|
+
"row.authors": "{count} authors",
|
|
514
|
+
"row.filterByTask": "Show only task \"{task}\"",
|
|
515
|
+
"row.filterByWorktree": "Show only checkout \"{worktree}\"",
|
|
516
|
+
"row.leaseHeld": "Checkout: currently claimed by this report",
|
|
517
|
+
"row.leaseReleased": "Checkout: claim ended, the checkout is still on disk",
|
|
518
|
+
"row.leaseReclaimed": "Checkout: cleared away by the ledger",
|
|
519
|
+
"row.leaseNone": "Checkout: bound but never claimed",
|
|
520
|
+
"row.expand": "Expand {report}",
|
|
521
|
+
"row.collapse": "Collapse {report}",
|
|
522
|
+
"row.last": "last",
|
|
523
|
+
"hop.authored": "authored",
|
|
524
|
+
"hop.contributed": "contributed",
|
|
525
|
+
"hop.sent": "sent",
|
|
526
|
+
"hop.delivered": "delivered",
|
|
527
|
+
"hop.cc": "cc",
|
|
528
|
+
"hop.forwarded": "forwarded",
|
|
529
|
+
"hop.copied": "copied",
|
|
530
|
+
"hop.read": "read",
|
|
531
|
+
"hop.acked": "acked",
|
|
532
|
+
"hop.leased": "leased",
|
|
533
|
+
"hop.released": "released",
|
|
534
|
+
"hop.reclaimed": "reclaimed",
|
|
535
|
+
"hop.amended": "amended",
|
|
536
|
+
"hop.closed": "closed",
|
|
537
|
+
"hop.reopened": "reopened",
|
|
538
|
+
"filter.all": "All",
|
|
539
|
+
"filter.open": "Open",
|
|
540
|
+
"filter.acked": "Acked",
|
|
541
|
+
"filter.closed": "Closed",
|
|
542
|
+
"filter.search": "Search subject, id, session, or people…",
|
|
543
|
+
"filter.clear": "Clear",
|
|
544
|
+
"filter.showing": "Showing {visible}/{total} reports",
|
|
545
|
+
"filter.none": "No reports match the current filter.",
|
|
546
|
+
"filter.hint": "The status filter narrows reports only; search matches every row, including session titles and ids, task labels and checkout names.",
|
|
547
|
+
"basis.created": "By creation",
|
|
548
|
+
"basis.updated": "By activity",
|
|
549
|
+
"basis.hint": "Whether reports are placed at creation or at their latest activity; sessions always keep their creation time.",
|
|
550
|
+
"detail.thread": "Thread",
|
|
551
|
+
"detail.parent": "Answers",
|
|
552
|
+
"detail.children": "Answered by",
|
|
553
|
+
"detail.openLinked": "Open {report}",
|
|
554
|
+
"detail.outside": "{report} is not in this session tree's list (it may belong to another line); its body and path are still readable.",
|
|
555
|
+
"detail.filteredOut": "{report} is hidden by the current filter (use Clear to restore it).",
|
|
556
|
+
"detail.backToList": "Back to the list",
|
|
557
|
+
"detail.bodyTruncated": "Body is long, so only the first {chars} characters are shown here; the ledger file has the whole thing.",
|
|
558
|
+
"cardgraph.external": "outside",
|
|
559
|
+
"cardgraph.zoomIn": "Zoom in",
|
|
560
|
+
"cardgraph.zoomOut": "Zoom out",
|
|
561
|
+
"cardgraph.zoom": "{percent}%",
|
|
562
|
+
"cardgraph.fit": "Fill width",
|
|
563
|
+
"cardgraph.summary": "{frames} frames · {cards} reports",
|
|
564
|
+
"cardgraph.empty": "no reports",
|
|
565
|
+
"cardgraph.gestureHint": "Wheel pans · Ctrl+wheel zooms",
|
|
566
|
+
"sidebar.guideTitle": "Report canvas",
|
|
567
|
+
"sidebar.guideDescription": "Dock the topology canvas into the right sidebar",
|
|
568
|
+
"sidebar.chip": "Report canvas",
|
|
569
|
+
"body.copy": "Copy",
|
|
570
|
+
"body.copied": "Copied",
|
|
571
|
+
"body.footnotes": "Footnotes"
|
|
572
|
+
};
|
|
242
573
|
/**
|
|
243
|
-
*
|
|
244
|
-
*
|
|
245
|
-
*
|
|
574
|
+
* Lifecycle state → dictionary key.
|
|
575
|
+
*
|
|
576
|
+
* Deliberately the SAME keys the toolbar's filter chips use. A card's status
|
|
577
|
+
* chip and the filter that selects it name one state, so giving them two
|
|
578
|
+
* vocabularies (raw `open` on the card, 进行中 in the toolbar) made the reader
|
|
579
|
+
* build the mapping themselves — and the mapping was the bug, not the words.
|
|
246
580
|
*/
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
581
|
+
const STATUS_LABEL = {
|
|
582
|
+
open: "filter.open",
|
|
583
|
+
acked: "filter.acked",
|
|
584
|
+
closed: "filter.closed"
|
|
585
|
+
};
|
|
586
|
+
/**
|
|
587
|
+
* Lifecycle state → the shell's chip tone.
|
|
588
|
+
*
|
|
589
|
+
* Colour-coded status is the point of the chip, and the shell's palette already
|
|
590
|
+
* has the three tones this needs — so the states ride `Tag` instead of a
|
|
591
|
+
* hand-mixed background. `open` reads as the one that wants attention. The tone
|
|
592
|
+
* names are the ones the shell's CSS actually defines (`[data-tone=…]`), which
|
|
593
|
+
* is a longer list than the shipped views happen to use.
|
|
594
|
+
*/
|
|
595
|
+
const STATUS_TONE = {
|
|
596
|
+
open: "warning",
|
|
597
|
+
acked: "success",
|
|
598
|
+
closed: "neutral"
|
|
599
|
+
};
|
|
600
|
+
/**
|
|
601
|
+
* Edge kind → the word the drawing calls it.
|
|
602
|
+
*
|
|
603
|
+
* The drawing borrows the transfer path's own vocabulary rather than inventing a
|
|
604
|
+
* second one: an arrow in the canvas and a row in the panel name the same
|
|
605
|
+
* relation, so they say the same thing.
|
|
606
|
+
*/
|
|
607
|
+
const EDGE_LABEL = {
|
|
608
|
+
to: "hop.sent",
|
|
609
|
+
cc: "hop.cc",
|
|
610
|
+
author: "hop.contributed",
|
|
611
|
+
thread: "detail.thread"
|
|
612
|
+
};
|
|
613
|
+
/** Lease-state description, for the checkout chip's tooltip. */
|
|
614
|
+
const LEASE_HINT = {
|
|
615
|
+
held: "row.leaseHeld",
|
|
616
|
+
released: "row.leaseReleased",
|
|
617
|
+
reclaimed: "row.leaseReclaimed",
|
|
618
|
+
none: "row.leaseNone"
|
|
619
|
+
};
|
|
620
|
+
/**
|
|
621
|
+
* Transfer-path hop action → dictionary key.
|
|
622
|
+
*
|
|
623
|
+
* These are ledger vocabulary: the model and the ledger files keep the raw
|
|
624
|
+
* tokens (`authored`, `delivered`, …), and only the human view speaks a
|
|
625
|
+
* language. Keyed by the full action union so a new hop action cannot ship
|
|
626
|
+
* without a word for it.
|
|
627
|
+
*/
|
|
628
|
+
const HOP_LABEL = {
|
|
629
|
+
authored: "hop.authored",
|
|
630
|
+
contributed: "hop.contributed",
|
|
631
|
+
sent: "hop.sent",
|
|
632
|
+
delivered: "hop.delivered",
|
|
633
|
+
cc: "hop.cc",
|
|
634
|
+
forwarded: "hop.forwarded",
|
|
635
|
+
copied: "hop.copied",
|
|
636
|
+
read: "hop.read",
|
|
637
|
+
acked: "hop.acked",
|
|
638
|
+
leased: "hop.leased",
|
|
639
|
+
released: "hop.released",
|
|
640
|
+
reclaimed: "hop.reclaimed",
|
|
641
|
+
amended: "hop.amended",
|
|
642
|
+
closed: "hop.closed",
|
|
643
|
+
reopened: "hop.reopened"
|
|
644
|
+
};
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/client/ReportDetailView.tsx
|
|
647
|
+
/**
|
|
648
|
+
* One report's detail: its transfer path, its thread links, and its body.
|
|
649
|
+
*
|
|
650
|
+
* Shared by the two surfaces that show a report — the conversation tab, where it
|
|
651
|
+
* opens in place under its row, and the right column's canvas tab, where it opens
|
|
652
|
+
* under the drawing. One implementation on purpose: the two are read side by
|
|
653
|
+
* side, and the same report must not read differently in them.
|
|
654
|
+
*
|
|
655
|
+
* The two variants differ in exactly two ways, both about who owns the space:
|
|
656
|
+
* a row's panel grows with the timeline's own scroll (so it has no inner scroll
|
|
657
|
+
* box — a nested scroller would only steal the wheel), while a docked panel hands
|
|
658
|
+
* over a finite height and the detail takes its share of it and scrolls inside.
|
|
659
|
+
*
|
|
660
|
+
* @module dsh-report-ledger/client/ReportDetailView
|
|
661
|
+
*/
|
|
662
|
+
const time = {
|
|
663
|
+
color: "var(--dsw-alias-label-caption, #999)",
|
|
664
|
+
fontSize: "var(--dsw-font-xxxs-11, 11)",
|
|
665
|
+
whiteSpace: "nowrap"
|
|
666
|
+
};
|
|
667
|
+
/**
|
|
668
|
+
* The same caption look, for content that must be allowed to WRAP.
|
|
669
|
+
*
|
|
670
|
+
* `time` sets `white-space: nowrap`, which is right for a timestamp and wrong for
|
|
671
|
+
* anything whose length the ledger decides: a nowrap element's min-content width
|
|
672
|
+
* is its whole unbreakable line, so inside a `1fr` grid track it grows the track
|
|
673
|
+
* past its container. Recipient lists and hop notes are exactly that — which is
|
|
674
|
+
* how the transfer path used to push the whole tab into a horizontal scrollbar.
|
|
675
|
+
*/
|
|
676
|
+
const wrap = {
|
|
677
|
+
...time,
|
|
678
|
+
whiteSpace: "normal",
|
|
679
|
+
minWidth: 0,
|
|
680
|
+
overflowWrap: "anywhere"
|
|
681
|
+
};
|
|
682
|
+
/** The frame the rendered body sits in — a block, not a scroll box. */
|
|
683
|
+
const bodyFrame = {
|
|
684
|
+
padding: "8px 10px",
|
|
685
|
+
background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
|
|
686
|
+
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
|
|
687
|
+
borderRadius: "4px"
|
|
688
|
+
};
|
|
689
|
+
/** The footer line: ledger path plus its copy control. */
|
|
690
|
+
const pathRow = {
|
|
691
|
+
...wrap,
|
|
692
|
+
marginTop: "6px",
|
|
693
|
+
display: "flex",
|
|
694
|
+
alignItems: "baseline",
|
|
695
|
+
gap: "6px"
|
|
696
|
+
};
|
|
697
|
+
/** A row's inline panel: indented under its row, growing with the page. */
|
|
698
|
+
const rowBox = {
|
|
699
|
+
margin: "4px 0 10px 24px",
|
|
700
|
+
padding: "10px 12px",
|
|
701
|
+
borderRadius: "6px",
|
|
702
|
+
background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.03))",
|
|
703
|
+
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
704
|
+
};
|
|
705
|
+
/** A docked panel's share: a column that owns its height and scrolls inside it. */
|
|
706
|
+
const pane = {
|
|
707
|
+
display: "flex",
|
|
708
|
+
flexDirection: "column",
|
|
709
|
+
height: "100%",
|
|
710
|
+
minHeight: 0,
|
|
711
|
+
padding: "8px 12px 10px",
|
|
712
|
+
borderTop: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.08))",
|
|
713
|
+
background: "var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.02))"
|
|
714
|
+
};
|
|
715
|
+
const paneHead = {
|
|
716
|
+
display: "flex",
|
|
717
|
+
alignItems: "center",
|
|
718
|
+
gap: "8px",
|
|
719
|
+
marginBottom: "6px"
|
|
720
|
+
};
|
|
721
|
+
const paneBody = {
|
|
722
|
+
flex: "1 1 auto",
|
|
723
|
+
minHeight: 0,
|
|
724
|
+
overflow: "auto"
|
|
725
|
+
};
|
|
726
|
+
/**
|
|
727
|
+
* Render one report: route, thread, body and path.
|
|
728
|
+
* @param props - the read result, the copy seat, and how the box is dressed.
|
|
729
|
+
* @returns the detail block.
|
|
730
|
+
*/
|
|
731
|
+
function ReportDetailView(props) {
|
|
732
|
+
const { report, detail, error, t, idHint, id, variant, onOpenReport, inList, visibleInFilter, onClose } = props;
|
|
733
|
+
const [copiedPath, setCopiedPath] = (0, react.useState)(false);
|
|
734
|
+
const copyTimer = (0, react.useRef)(void 0);
|
|
735
|
+
(0, react.useEffect)(() => () => {
|
|
736
|
+
if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
|
|
326
737
|
}, []);
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
};
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
};
|
|
370
|
-
const time = {
|
|
371
|
-
color: "var(--dsw-alias-label-caption, #999)",
|
|
372
|
-
fontSize: "var(--dsw-font-xxxs-11, 11)",
|
|
373
|
-
whiteSpace: "nowrap"
|
|
374
|
-
};
|
|
375
|
-
if (error !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
376
|
-
style: shell,
|
|
377
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
378
|
-
style: { color: "var(--dsw-alias-state-error-label, #b00)" },
|
|
379
|
-
children: t("view.error", { code: error })
|
|
380
|
-
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
381
|
-
style: { marginTop: "8px" },
|
|
382
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
383
|
-
type: "button",
|
|
384
|
-
style: button,
|
|
385
|
-
onClick: refresh,
|
|
386
|
-
children: t("view.retry")
|
|
738
|
+
const shownBody = (0, react.useMemo)(() => displayBody(detail?.body ?? ""), [detail]);
|
|
739
|
+
const bodyText = (0, react.useMemo)(() => {
|
|
740
|
+
const text = shownBody.text.trim();
|
|
741
|
+
return shownBody.truncated ? closeOpenFence(text) : text;
|
|
742
|
+
}, [shownBody]);
|
|
743
|
+
/** Strings for the prose renderer's own controls, from this plugin's dictionary. */
|
|
744
|
+
const markdownLabels = (0, react.useMemo)(() => ({
|
|
745
|
+
code: {
|
|
746
|
+
copyLabel: t("body.copy"),
|
|
747
|
+
copiedLabel: t("body.copied")
|
|
748
|
+
},
|
|
749
|
+
footnotes: t("body.footnotes")
|
|
750
|
+
}), [t]);
|
|
751
|
+
/**
|
|
752
|
+
* Copy the ledger path.
|
|
753
|
+
*
|
|
754
|
+
* Uses the shell's `writeClipboard` rather than `navigator.clipboard`, so the
|
|
755
|
+
* plugin inherits whatever fallback chain the rest of the GUI relies on.
|
|
756
|
+
*/
|
|
757
|
+
const copyPath = (0, react.useCallback)((path) => {
|
|
758
|
+
(0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(path).then((ok) => {
|
|
759
|
+
if (!ok) return;
|
|
760
|
+
setCopiedPath(true);
|
|
761
|
+
if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
|
|
762
|
+
copyTimer.current = globalThis.setTimeout(() => {
|
|
763
|
+
setCopiedPath(false);
|
|
764
|
+
}, COPIED_HOLD_MS);
|
|
765
|
+
});
|
|
766
|
+
}, []);
|
|
767
|
+
const link = (target) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
768
|
+
style: { marginRight: "6px" },
|
|
769
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
770
|
+
label: fill(t("detail.openLinked"), { report: target }),
|
|
771
|
+
side: "bottom",
|
|
772
|
+
delayMs: 400,
|
|
773
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
774
|
+
variant: "outline",
|
|
775
|
+
size: "sm",
|
|
776
|
+
onClick: () => {
|
|
777
|
+
onOpenReport(target);
|
|
778
|
+
},
|
|
779
|
+
children: target
|
|
387
780
|
})
|
|
388
|
-
})
|
|
389
|
-
});
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
781
|
+
})
|
|
782
|
+
}, target);
|
|
783
|
+
const content = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
784
|
+
error !== void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
785
|
+
style: { color: "var(--dsw-alias-state-error-primary, #b00)" },
|
|
786
|
+
children: t("view.error", { code: error })
|
|
787
|
+
}) : null,
|
|
788
|
+
detail === void 0 && error === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
789
|
+
style: time,
|
|
790
|
+
children: t("view.detail.loading")
|
|
791
|
+
}) : null,
|
|
792
|
+
detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
793
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
794
|
+
style: { marginBottom: "6px" },
|
|
795
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.route") })
|
|
796
|
+
}),
|
|
797
|
+
detail.hops.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
798
|
+
style: time,
|
|
799
|
+
children: t("view.detail.routeEmpty")
|
|
800
|
+
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
801
|
+
style: {
|
|
802
|
+
display: "grid",
|
|
803
|
+
gridTemplateColumns: "auto auto auto 1fr",
|
|
804
|
+
gap: "2px 10px",
|
|
805
|
+
marginBottom: "8px"
|
|
806
|
+
},
|
|
807
|
+
children: detail.hops.map((hop, hopIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
|
|
413
808
|
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
414
|
-
style:
|
|
415
|
-
children:
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
809
|
+
style: time,
|
|
810
|
+
children: stamp(hop.at)
|
|
811
|
+
}),
|
|
812
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t(HOP_LABEL[hop.action]) }) }),
|
|
813
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
814
|
+
style: wrap,
|
|
815
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
816
|
+
label: idHint(hop.actor),
|
|
817
|
+
side: "bottom",
|
|
818
|
+
delayMs: 400,
|
|
819
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hop.actor })
|
|
421
820
|
})
|
|
422
821
|
}),
|
|
423
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
onClick: refresh,
|
|
427
|
-
children: t("view.refresh")
|
|
822
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
823
|
+
style: wrap,
|
|
824
|
+
children: hop.to.length === 0 ? hop.note ?? "" : `→ ${hop.to.join(", ")}${hop.note === void 0 ? "" : ` (${hop.note})`}`
|
|
428
825
|
})
|
|
429
|
-
]
|
|
826
|
+
] }, `h-${hopIndex}`))
|
|
430
827
|
}),
|
|
431
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
432
|
-
style:
|
|
433
|
-
|
|
828
|
+
detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
829
|
+
style: {
|
|
830
|
+
...wrap,
|
|
831
|
+
display: "flex",
|
|
832
|
+
alignItems: "baseline",
|
|
833
|
+
gap: "6px"
|
|
834
|
+
},
|
|
835
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
836
|
+
state: "warning",
|
|
837
|
+
size: 8
|
|
838
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: fill(t("view.detail.pending"), { targets: detail.pending.join(", ") }) })]
|
|
839
|
+
}),
|
|
840
|
+
(() => {
|
|
841
|
+
const parent = detail.front.parent;
|
|
842
|
+
const children = detail.front.children;
|
|
843
|
+
if (parent === void 0 && children.length === 0) return null;
|
|
844
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
845
|
+
style: { marginBottom: "8px" },
|
|
846
|
+
children: [
|
|
847
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("detail.thread") }),
|
|
848
|
+
parent === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
849
|
+
style: { marginTop: "2px" },
|
|
850
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
851
|
+
style: time,
|
|
852
|
+
children: `${t("detail.parent")}: `
|
|
853
|
+
}), link(parent)]
|
|
854
|
+
}),
|
|
855
|
+
children.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
856
|
+
style: { marginTop: "2px" },
|
|
857
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
858
|
+
style: time,
|
|
859
|
+
children: `${t("detail.children")}: `
|
|
860
|
+
}), children.map(link)]
|
|
861
|
+
})
|
|
862
|
+
]
|
|
863
|
+
});
|
|
864
|
+
})(),
|
|
865
|
+
inList ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
866
|
+
style: {
|
|
867
|
+
...wrap,
|
|
868
|
+
marginBottom: "8px"
|
|
869
|
+
},
|
|
434
870
|
children: [
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
"
|
|
439
|
-
"closed"
|
|
440
|
-
].map((candidate) => {
|
|
441
|
-
const active = status === candidate;
|
|
442
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
443
|
-
type: "button",
|
|
444
|
-
"aria-pressed": active,
|
|
445
|
-
onClick: () => {
|
|
446
|
-
setStatus(candidate);
|
|
447
|
-
},
|
|
448
|
-
style: {
|
|
449
|
-
...button,
|
|
450
|
-
...active ? {
|
|
451
|
-
color: "var(--dsw-alias-label-primary, inherit)",
|
|
452
|
-
background: "var(--dsw-alias-interactive-bg-active, rgba(0,0,0,0.08))",
|
|
453
|
-
borderColor: "var(--dsw-alias-border-l3, rgba(0,0,0,0.20))"
|
|
454
|
-
} : {}
|
|
455
|
-
},
|
|
456
|
-
children: `${t(statusLabel[candidate])} ${statusCount[candidate]}`
|
|
457
|
-
}, candidate);
|
|
458
|
-
}),
|
|
459
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
|
|
460
|
-
type: "search",
|
|
461
|
-
value: text,
|
|
462
|
-
placeholder: t("filter.search"),
|
|
463
|
-
"aria-label": t("filter.search"),
|
|
464
|
-
onChange: (event) => {
|
|
465
|
-
setText(event.target.value);
|
|
466
|
-
},
|
|
467
|
-
style: {
|
|
468
|
-
flex: "1 1 200px",
|
|
469
|
-
minWidth: "140px",
|
|
470
|
-
padding: "3px 8px",
|
|
471
|
-
font: "inherit",
|
|
472
|
-
color: "var(--dsw-alias-label-primary, inherit)",
|
|
473
|
-
background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
|
|
474
|
-
border: "1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))",
|
|
475
|
-
borderRadius: "6px"
|
|
476
|
-
}
|
|
477
|
-
}),
|
|
478
|
-
filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
479
|
-
type: "button",
|
|
480
|
-
style: button,
|
|
481
|
-
onClick: () => {
|
|
482
|
-
setStatus("all");
|
|
483
|
-
setText("");
|
|
484
|
-
},
|
|
485
|
-
children: t("filter.clear")
|
|
486
|
-
}) : null,
|
|
487
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
488
|
-
type: "button",
|
|
489
|
-
style: button,
|
|
490
|
-
title: t("basis.hint"),
|
|
871
|
+
fill(t("detail.outside"), { report: detail.front.report }),
|
|
872
|
+
" ",
|
|
873
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
874
|
+
variant: "outline",
|
|
491
875
|
onClick: () => {
|
|
492
|
-
|
|
876
|
+
onOpenReport(void 0);
|
|
493
877
|
},
|
|
494
|
-
children: t(
|
|
878
|
+
children: t("detail.backToList")
|
|
495
879
|
})
|
|
496
880
|
]
|
|
497
881
|
}),
|
|
498
|
-
|
|
499
|
-
style: {
|
|
500
|
-
|
|
882
|
+
inList && !visibleInFilter ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
883
|
+
style: {
|
|
884
|
+
...wrap,
|
|
885
|
+
marginBottom: "8px"
|
|
886
|
+
},
|
|
887
|
+
children: fill(t("detail.filteredOut"), { report: detail.front.report })
|
|
501
888
|
}) : null,
|
|
502
|
-
|
|
503
|
-
style: {
|
|
504
|
-
children: t("
|
|
889
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
890
|
+
style: { margin: "8px 0 4px" },
|
|
891
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.body") })
|
|
892
|
+
}),
|
|
893
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
894
|
+
style: bodyFrame,
|
|
895
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
|
|
896
|
+
text: bodyText,
|
|
897
|
+
labels: markdownLabels
|
|
898
|
+
})
|
|
899
|
+
}),
|
|
900
|
+
shownBody.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
901
|
+
style: {
|
|
902
|
+
...wrap,
|
|
903
|
+
marginTop: "4px"
|
|
904
|
+
},
|
|
905
|
+
children: fill(t("detail.bodyTruncated"), { chars: shownBody.text.trim().length })
|
|
505
906
|
}) : null,
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
children: [
|
|
516
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
517
|
-
style: { color: "var(--dsw-alias-label-tertiary, #999)" },
|
|
518
|
-
children: "●"
|
|
519
|
-
}),
|
|
520
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
521
|
-
style: { flex: "1 1 auto" },
|
|
522
|
-
children: [
|
|
523
|
-
row.delegated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
524
|
-
style: chip({
|
|
525
|
-
color: "var(--dsw-alias-state-business-label, #0969da)",
|
|
526
|
-
background: "var(--dsw-alias-state-business-bg, rgba(9,105,218,0.10))"
|
|
527
|
-
}),
|
|
528
|
-
children: t("view.delegated")
|
|
529
|
-
}) : null,
|
|
530
|
-
row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
531
|
-
style: chip({
|
|
532
|
-
color: "var(--dsw-alias-state-success-label, #1a7f37)",
|
|
533
|
-
background: "var(--dsw-alias-state-success-bg, rgba(26,127,55,0.10))"
|
|
534
|
-
}),
|
|
535
|
-
children: t("view.live")
|
|
536
|
-
}) : null,
|
|
537
|
-
row.title ?? row.id
|
|
538
|
-
]
|
|
539
|
-
}),
|
|
540
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
541
|
-
style: time,
|
|
542
|
-
children: stamp(row.at)
|
|
543
|
-
})
|
|
544
|
-
]
|
|
545
|
-
}, `s-${row.id}-${index}`);
|
|
546
|
-
const front = row.front;
|
|
547
|
-
const expanded = openReport === front.report;
|
|
548
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
549
|
-
style: { paddingLeft: `${pad}px` },
|
|
550
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
551
|
-
role: "button",
|
|
552
|
-
tabIndex: 0,
|
|
553
|
-
onClick: () => {
|
|
554
|
-
setOpenReport(expanded ? void 0 : front.report);
|
|
555
|
-
},
|
|
556
|
-
onKeyDown: (event) => {
|
|
557
|
-
if (event.key === "Enter" || event.key === " ") {
|
|
558
|
-
event.preventDefault();
|
|
559
|
-
setOpenReport(expanded ? void 0 : front.report);
|
|
560
|
-
}
|
|
561
|
-
},
|
|
562
|
-
style: {
|
|
563
|
-
display: "flex",
|
|
564
|
-
gap: "8px",
|
|
565
|
-
alignItems: "baseline",
|
|
566
|
-
padding: "5px 8px",
|
|
567
|
-
margin: "2px 0",
|
|
568
|
-
borderRadius: "6px",
|
|
569
|
-
cursor: "pointer",
|
|
570
|
-
background: "var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.02))",
|
|
571
|
-
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
572
|
-
},
|
|
573
|
-
children: [
|
|
574
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
575
|
-
style: { color: "var(--dsw-alias-label-tertiary, #999)" },
|
|
576
|
-
children: expanded ? "▾" : "▸"
|
|
577
|
-
}),
|
|
578
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
579
|
-
style: { flex: "1 1 auto" },
|
|
580
|
-
children: [
|
|
581
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
582
|
-
style: chip(statusStyle(front.status)),
|
|
583
|
-
children: front.status
|
|
584
|
-
}),
|
|
585
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: front.report }),
|
|
586
|
-
" ",
|
|
587
|
-
front.subject,
|
|
588
|
-
front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
589
|
-
type: "button",
|
|
590
|
-
title: fill(t("row.filterByTask"), { task: front.task }),
|
|
591
|
-
onClick: (event) => {
|
|
592
|
-
event.stopPropagation();
|
|
593
|
-
setText(front.task);
|
|
594
|
-
},
|
|
595
|
-
style: {
|
|
596
|
-
...chip({
|
|
597
|
-
color: "var(--dsw-alias-state-business-label, #0969da)",
|
|
598
|
-
background: "var(--dsw-alias-state-business-bg, rgba(9,105,218,0.10))"
|
|
599
|
-
}),
|
|
600
|
-
border: 0,
|
|
601
|
-
cursor: "pointer",
|
|
602
|
-
font: "inherit"
|
|
603
|
-
},
|
|
604
|
-
children: front.task
|
|
605
|
-
}),
|
|
606
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
607
|
-
style: {
|
|
608
|
-
...time,
|
|
609
|
-
marginTop: "2px"
|
|
610
|
-
},
|
|
611
|
-
children: [
|
|
612
|
-
`from=${front.from}`,
|
|
613
|
-
front.to.length > 0 ? ` → ${front.to.join(", ")}` : "",
|
|
614
|
-
front.cc.length > 0 ? ` cc ${front.cc.join(", ")}` : "",
|
|
615
|
-
` · ${fill(t("row.hops"), { count: front.hops })}`,
|
|
616
|
-
front.authors.length > 1 ? ` · ${fill(t("row.authors"), { count: front.authors.length })}` : "",
|
|
617
|
-
front.last === void 0 ? "" : ` · last=${front.last.action}`
|
|
618
|
-
]
|
|
619
|
-
})
|
|
620
|
-
]
|
|
621
|
-
}),
|
|
622
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
623
|
-
style: time,
|
|
624
|
-
children: stamp(front.updated)
|
|
625
|
-
})
|
|
626
|
-
]
|
|
627
|
-
}), expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
628
|
-
style: {
|
|
629
|
-
margin: "4px 0 10px 24px",
|
|
630
|
-
padding: "10px 12px",
|
|
631
|
-
borderRadius: "6px",
|
|
632
|
-
background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.03))",
|
|
633
|
-
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
634
|
-
},
|
|
907
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
908
|
+
style: pathRow,
|
|
909
|
+
children: [
|
|
910
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
911
|
+
state: "idle",
|
|
912
|
+
size: 6
|
|
913
|
+
}),
|
|
914
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
915
|
+
style: { wordBreak: "break-all" },
|
|
635
916
|
children: [
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
}) : null,
|
|
640
|
-
detail === void 0 && detailError === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
641
|
-
style: time,
|
|
642
|
-
children: t("view.detail.loading")
|
|
643
|
-
}) : null,
|
|
644
|
-
detail === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
|
|
645
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
646
|
-
style: { marginBottom: "6px" },
|
|
647
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.route") })
|
|
648
|
-
}),
|
|
649
|
-
detail.hops.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
650
|
-
style: time,
|
|
651
|
-
children: t("view.detail.routeEmpty")
|
|
652
|
-
}) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
653
|
-
style: {
|
|
654
|
-
display: "grid",
|
|
655
|
-
gridTemplateColumns: "auto auto auto 1fr",
|
|
656
|
-
gap: "2px 10px",
|
|
657
|
-
marginBottom: "8px"
|
|
658
|
-
},
|
|
659
|
-
children: detail.hops.map((hop, hopIndex) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [
|
|
660
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
661
|
-
style: time,
|
|
662
|
-
children: stamp(hop.at)
|
|
663
|
-
}),
|
|
664
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: hop.action }) }),
|
|
665
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
666
|
-
style: time,
|
|
667
|
-
children: hop.actor
|
|
668
|
-
}),
|
|
669
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
670
|
-
style: time,
|
|
671
|
-
children: hop.to.length === 0 ? hop.note ?? "" : `→ ${hop.to.join(", ")}${hop.note === void 0 ? "" : ` (${hop.note})`}`
|
|
672
|
-
})
|
|
673
|
-
] }, `h-${hopIndex}`))
|
|
674
|
-
}),
|
|
675
|
-
detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
676
|
-
style: {
|
|
677
|
-
...time,
|
|
678
|
-
color: "var(--dsw-alias-state-warn-label, #9a6700)"
|
|
679
|
-
},
|
|
680
|
-
children: fill(t("view.detail.pending"), { targets: detail.pending.join(", ") })
|
|
681
|
-
}),
|
|
682
|
-
(() => {
|
|
683
|
-
const parent = detail.front.parent;
|
|
684
|
-
const children = detail.front.children;
|
|
685
|
-
if (parent === void 0 && children.length === 0) return null;
|
|
686
|
-
const link = (target) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
687
|
-
type: "button",
|
|
688
|
-
title: fill(t("detail.openLinked"), { report: target }),
|
|
689
|
-
onClick: () => {
|
|
690
|
-
setOpenReport(target);
|
|
691
|
-
},
|
|
692
|
-
style: {
|
|
693
|
-
...button,
|
|
694
|
-
marginRight: "6px",
|
|
695
|
-
font: "var(--dsw-font-xxxs-11, 11px/1.4 ui-monospace, monospace)"
|
|
696
|
-
},
|
|
697
|
-
children: target
|
|
698
|
-
}, target);
|
|
699
|
-
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
700
|
-
style: { marginBottom: "8px" },
|
|
701
|
-
children: [
|
|
702
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("detail.thread") }),
|
|
703
|
-
parent === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
704
|
-
style: { marginTop: "2px" },
|
|
705
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
706
|
-
style: time,
|
|
707
|
-
children: `${t("detail.parent")}: `
|
|
708
|
-
}), link(parent)]
|
|
709
|
-
}),
|
|
710
|
-
children.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
711
|
-
style: { marginTop: "2px" },
|
|
712
|
-
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
713
|
-
style: time,
|
|
714
|
-
children: `${t("detail.children")}: `
|
|
715
|
-
}), children.map(link)]
|
|
716
|
-
})
|
|
717
|
-
]
|
|
718
|
-
});
|
|
719
|
-
})(),
|
|
720
|
-
inList.has(detail.front.report) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
721
|
-
style: {
|
|
722
|
-
...time,
|
|
723
|
-
marginBottom: "8px"
|
|
724
|
-
},
|
|
725
|
-
children: [
|
|
726
|
-
fill(t("detail.outside"), { report: detail.front.report }),
|
|
727
|
-
" ",
|
|
728
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
729
|
-
type: "button",
|
|
730
|
-
style: button,
|
|
731
|
-
onClick: () => {
|
|
732
|
-
setOpenReport(void 0);
|
|
733
|
-
},
|
|
734
|
-
children: t("detail.backToList")
|
|
735
|
-
})
|
|
736
|
-
]
|
|
737
|
-
}),
|
|
738
|
-
inList.has(detail.front.report) && !filteredReportIds.has(detail.front.report) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
739
|
-
style: {
|
|
740
|
-
...time,
|
|
741
|
-
marginBottom: "8px"
|
|
742
|
-
},
|
|
743
|
-
children: fill(t("detail.filteredOut"), { report: detail.front.report })
|
|
744
|
-
}) : null,
|
|
745
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
746
|
-
style: { margin: "8px 0 4px" },
|
|
747
|
-
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.body") })
|
|
748
|
-
}),
|
|
749
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("pre", {
|
|
750
|
-
style: {
|
|
751
|
-
margin: 0,
|
|
752
|
-
padding: "8px 10px",
|
|
753
|
-
maxHeight: "320px",
|
|
754
|
-
overflow: "auto",
|
|
755
|
-
whiteSpace: "pre-wrap",
|
|
756
|
-
wordBreak: "break-word",
|
|
757
|
-
background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
|
|
758
|
-
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
|
|
759
|
-
borderRadius: "4px",
|
|
760
|
-
font: "var(--dsw-font-xs-13, 13px/1.5 ui-monospace, monospace)"
|
|
761
|
-
},
|
|
762
|
-
children: shownBody.text.trim()
|
|
763
|
-
}),
|
|
764
|
-
shownBody.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
765
|
-
style: {
|
|
766
|
-
...time,
|
|
767
|
-
marginTop: "4px"
|
|
768
|
-
},
|
|
769
|
-
children: fill(t("detail.bodyTruncated"), { chars: shownBody.text.trim().length })
|
|
770
|
-
}) : null,
|
|
771
|
-
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
772
|
-
style: {
|
|
773
|
-
...time,
|
|
774
|
-
marginTop: "6px",
|
|
775
|
-
wordBreak: "break-all"
|
|
776
|
-
},
|
|
777
|
-
children: [
|
|
778
|
-
t("view.detail.path"),
|
|
779
|
-
": ",
|
|
780
|
-
detail.path
|
|
781
|
-
]
|
|
782
|
-
})
|
|
783
|
-
] })
|
|
917
|
+
t("view.detail.path"),
|
|
918
|
+
": ",
|
|
919
|
+
detail.path
|
|
784
920
|
]
|
|
785
|
-
})
|
|
786
|
-
|
|
921
|
+
}),
|
|
922
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
923
|
+
label: copiedPath ? t("body.copied") : t("view.copyPath"),
|
|
924
|
+
side: "bottom",
|
|
925
|
+
delayMs: 300,
|
|
926
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
927
|
+
variant: "ghost",
|
|
928
|
+
size: "sm",
|
|
929
|
+
icon: copiedPath ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCheckOutlineRegular, { size: 16 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconCopyOutlineRegular, { size: 16 }),
|
|
930
|
+
"aria-label": copiedPath ? t("body.copied") : t("view.copyPath"),
|
|
931
|
+
onClick: () => {
|
|
932
|
+
copyPath(detail.path);
|
|
933
|
+
}
|
|
934
|
+
})
|
|
935
|
+
})
|
|
936
|
+
]
|
|
787
937
|
})
|
|
788
|
-
]
|
|
938
|
+
] })
|
|
939
|
+
] });
|
|
940
|
+
if (variant === "pane") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
941
|
+
style: pane,
|
|
942
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
943
|
+
style: paneHead,
|
|
944
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: report }), onClose === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
945
|
+
variant: "ghost",
|
|
946
|
+
size: "sm",
|
|
947
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 14 }),
|
|
948
|
+
"aria-label": fill(t("row.collapse"), { report }),
|
|
949
|
+
onClick: onClose
|
|
950
|
+
})]
|
|
951
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
952
|
+
style: paneBody,
|
|
953
|
+
children: content
|
|
954
|
+
})]
|
|
955
|
+
});
|
|
956
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
957
|
+
id,
|
|
958
|
+
style: rowBox,
|
|
959
|
+
children: content
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
//#endregion
|
|
963
|
+
//#region src/client/timeline-source.ts
|
|
964
|
+
/**
|
|
965
|
+
* The ledger's browser-side read path: the two endpoints, the poll cadence, and
|
|
966
|
+
* the one way any view reads them.
|
|
967
|
+
*
|
|
968
|
+
* Two surfaces now draw the same ledger for the same session — the conversation
|
|
969
|
+
* tab and the canvas tab in the right column. Keeping the envelope unwrapping
|
|
970
|
+
* and the polling rule here is what stops the second surface from growing a
|
|
971
|
+
* second opinion about either; that kind of drift does not fail loudly, it just
|
|
972
|
+
* leaves one of the two views quietly rendering nothing.
|
|
973
|
+
*
|
|
974
|
+
* @module dsh-report-ledger/client/timeline-source
|
|
975
|
+
*/
|
|
976
|
+
/** The timeline read endpoint, addressed by the tree's root session. */
|
|
977
|
+
const TIMELINE_URL = "/api/report-ledger/timeline";
|
|
978
|
+
/** One report's detail: its transfer path and its body. */
|
|
979
|
+
const REPORT_URL = "/api/report-ledger/report";
|
|
980
|
+
/**
|
|
981
|
+
* How often a mounted view re-reads the timeline.
|
|
982
|
+
*
|
|
983
|
+
* The ledger is written by other agents in the background, so a view that loads
|
|
984
|
+
* once goes stale precisely when it matters — the human is watching for a report
|
|
985
|
+
* that has not been written yet. Ten seconds is quiet enough not to matter on a
|
|
986
|
+
* large ledger and still fast enough to watch a collaboration happen.
|
|
987
|
+
*/
|
|
988
|
+
const POLL_INTERVAL_MS = 1e4;
|
|
989
|
+
/** Read one endpoint and unwrap its result envelope. */
|
|
990
|
+
async function getJson(url) {
|
|
991
|
+
const response = await fetch(url, {
|
|
992
|
+
credentials: "same-origin",
|
|
993
|
+
headers: { accept: "application/json" }
|
|
994
|
+
});
|
|
995
|
+
let body;
|
|
996
|
+
try {
|
|
997
|
+
body = await response.json();
|
|
998
|
+
} catch {
|
|
999
|
+
throw new Error(`HTTP ${response.status}`);
|
|
1000
|
+
}
|
|
1001
|
+
const envelope = body;
|
|
1002
|
+
if (envelope.ok !== true) throw new Error(envelope.code ?? `HTTP ${response.status}`);
|
|
1003
|
+
return envelope.value;
|
|
1004
|
+
}
|
|
1005
|
+
/**
|
|
1006
|
+
* Follow one session tree's timeline for as long as the caller is mounted.
|
|
1007
|
+
*
|
|
1008
|
+
* Reads on mount and on every session change, then on the poll cadence. Polling
|
|
1009
|
+
* stops while the page is hidden and a returning page catches up immediately, so
|
|
1010
|
+
* a background panel costs nothing and a foreground one is never behind.
|
|
1011
|
+
* @param sessionId - the tree's root session; an empty value reads nothing.
|
|
1012
|
+
* @returns the latest payload, its error, and a manual refresh.
|
|
1013
|
+
*/
|
|
1014
|
+
function useTimeline(sessionId) {
|
|
1015
|
+
const [payload, setPayload] = (0, react.useState)(void 0);
|
|
1016
|
+
const [error, setError] = (0, react.useState)(void 0);
|
|
1017
|
+
const [nonce, setNonce] = (0, react.useState)(0);
|
|
1018
|
+
(0, react.useEffect)(() => {
|
|
1019
|
+
if (sessionId === "") {
|
|
1020
|
+
setError("no-session");
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
let cancelled = false;
|
|
1024
|
+
setError(void 0);
|
|
1025
|
+
getJson(`${TIMELINE_URL}?root=${encodeURIComponent(sessionId)}`).then((value) => {
|
|
1026
|
+
if (!cancelled) setPayload(value);
|
|
1027
|
+
}).catch((cause) => {
|
|
1028
|
+
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
|
|
1029
|
+
});
|
|
1030
|
+
return () => {
|
|
1031
|
+
cancelled = true;
|
|
1032
|
+
};
|
|
1033
|
+
}, [sessionId, nonce]);
|
|
1034
|
+
(0, react.useEffect)(() => {
|
|
1035
|
+
if (sessionId === "") return void 0;
|
|
1036
|
+
const tick = () => {
|
|
1037
|
+
if (globalThis.document?.visibilityState === "hidden") return;
|
|
1038
|
+
setNonce((value) => value + 1);
|
|
1039
|
+
};
|
|
1040
|
+
const timer = globalThis.setInterval(tick, POLL_INTERVAL_MS);
|
|
1041
|
+
const onVisibility = () => {
|
|
1042
|
+
if (globalThis.document?.visibilityState !== "hidden") tick();
|
|
1043
|
+
};
|
|
1044
|
+
globalThis.document?.addEventListener("visibilitychange", onVisibility);
|
|
1045
|
+
return () => {
|
|
1046
|
+
globalThis.clearInterval(timer);
|
|
1047
|
+
globalThis.document?.removeEventListener("visibilitychange", onVisibility);
|
|
1048
|
+
};
|
|
1049
|
+
}, [sessionId]);
|
|
1050
|
+
return {
|
|
1051
|
+
payload,
|
|
1052
|
+
error,
|
|
1053
|
+
refresh: (0, react.useCallback)(() => {
|
|
1054
|
+
setNonce((value) => value + 1);
|
|
1055
|
+
}, [])
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Read one report's detail, for whichever surface is showing it.
|
|
1060
|
+
*
|
|
1061
|
+
* Not polled on its own: the caller owns the cadence, because the two surfaces
|
|
1062
|
+
* want different ones — the conversation tab re-reads it when the reader hits
|
|
1063
|
+
* refresh, while a docked panel passes its own timeline tick so a report being
|
|
1064
|
+
* written under the reader's eyes keeps up.
|
|
1065
|
+
*
|
|
1066
|
+
* A re-read of the SAME report therefore keeps the previous detail on screen
|
|
1067
|
+
* until the new one arrives: clearing it would turn every tick into a flash of
|
|
1068
|
+
* "reading the transfer path" over a body the reader is in the middle of. A read
|
|
1069
|
+
* for a DIFFERENT report is a different question, so the state is keyed by report
|
|
1070
|
+
* id and anything read for another report is never returned — that is what stops
|
|
1071
|
+
* one click from rendering the previous report's body under the new one, even for
|
|
1072
|
+
* a frame.
|
|
1073
|
+
*
|
|
1074
|
+
* Errors follow the same rule: a failed re-read keeps the detail and reports the
|
|
1075
|
+
* failure beside it, and only a successful read clears it.
|
|
1076
|
+
* @param report - the report to read; `undefined` clears the read.
|
|
1077
|
+
* @param nonce - bump to re-read the same report.
|
|
1078
|
+
* @returns the detail for `report` only, its error, and nothing else.
|
|
1079
|
+
*/
|
|
1080
|
+
function useReportDetail(report, nonce) {
|
|
1081
|
+
const [state, setState] = (0, react.useState)(void 0);
|
|
1082
|
+
(0, react.useEffect)(() => {
|
|
1083
|
+
if (report === void 0) return void 0;
|
|
1084
|
+
let cancelled = false;
|
|
1085
|
+
getJson(`${REPORT_URL}?id=${encodeURIComponent(report)}`).then((value) => {
|
|
1086
|
+
if (!cancelled) setState({
|
|
1087
|
+
report,
|
|
1088
|
+
detail: value
|
|
1089
|
+
});
|
|
1090
|
+
}).catch((cause) => {
|
|
1091
|
+
if (cancelled) return;
|
|
1092
|
+
const message = cause instanceof Error ? cause.message : String(cause);
|
|
1093
|
+
setState((previous) => previous !== void 0 && previous.report === report && previous.detail !== void 0 ? {
|
|
1094
|
+
report,
|
|
1095
|
+
detail: previous.detail,
|
|
1096
|
+
error: message
|
|
1097
|
+
} : {
|
|
1098
|
+
report,
|
|
1099
|
+
error: message
|
|
1100
|
+
});
|
|
1101
|
+
});
|
|
1102
|
+
return () => {
|
|
1103
|
+
cancelled = true;
|
|
1104
|
+
};
|
|
1105
|
+
}, [report, nonce]);
|
|
1106
|
+
if (report === void 0 || state === void 0 || state.report !== report) return {
|
|
1107
|
+
detail: void 0,
|
|
1108
|
+
error: void 0
|
|
1109
|
+
};
|
|
1110
|
+
return {
|
|
1111
|
+
detail: state.detail,
|
|
1112
|
+
error: state.error
|
|
1113
|
+
};
|
|
1114
|
+
}
|
|
1115
|
+
//#endregion
|
|
1116
|
+
//#region src/client/styles.ts
|
|
1117
|
+
/**
|
|
1118
|
+
* The plugin's own stylesheet.
|
|
1119
|
+
*
|
|
1120
|
+
* The shell's primitives ship their own CSS with the shell, which is the whole
|
|
1121
|
+
* point of using them — a plugin gets the app's real look without carrying a
|
|
1122
|
+
* theme. What is left for us is the one thing only a consumer can know:
|
|
1123
|
+
*
|
|
1124
|
+
* **Layout that belongs to the consumer.** `Input` sizes itself to its content
|
|
1125
|
+
* (`display: inline-flex`, no width), which is right in a form and wrong in this
|
|
1126
|
+
* tab's toolbar, where the search seat takes the slack. The component puts the
|
|
1127
|
+
* `className` it is given on its own wrapper, so one rule is enough — and
|
|
1128
|
+
* targeting a class of our own keeps us off the hashed class names, which are
|
|
1129
|
+
* free to change between shell releases.
|
|
1130
|
+
*
|
|
1131
|
+
* Nothing here restyles a shell component; every library element keeps the look
|
|
1132
|
+
* the shell gave it. The SVG drawing rules that used to live here went with the
|
|
1133
|
+
* lane view they drew (R-0011): the topology is now painted by the card canvas,
|
|
1134
|
+
* which carries its own painter, so a stylesheet entry for a rail or an edge had
|
|
1135
|
+
* no consumer left.
|
|
1136
|
+
*
|
|
1137
|
+
* @module dsh-report-ledger/client/styles
|
|
1138
|
+
*/
|
|
1139
|
+
/** Class the tab puts on the toolbar's search seat. */
|
|
1140
|
+
const SEARCH_CLASS = "report-ledger-search";
|
|
1141
|
+
/** Element id, so a reload replaces the sheet instead of stacking one per apply. */
|
|
1142
|
+
const STYLE_ID = "dsh-report-ledger-styles";
|
|
1143
|
+
/** The adapter rules, kept in one place so the id and the text cannot drift. */
|
|
1144
|
+
const CSS = `
|
|
1145
|
+
.${SEARCH_CLASS} { flex: 1 1 auto; min-width: 0; }
|
|
1146
|
+
`;
|
|
1147
|
+
/**
|
|
1148
|
+
* Mount the adapter stylesheet.
|
|
1149
|
+
* @returns a disposer that removes it, owned by the plugin's effect scope.
|
|
1150
|
+
*/
|
|
1151
|
+
function installStyles() {
|
|
1152
|
+
const doc = globalThis.document;
|
|
1153
|
+
if (doc === void 0) return () => {};
|
|
1154
|
+
doc.getElementById(STYLE_ID)?.remove();
|
|
1155
|
+
const style = doc.createElement("style");
|
|
1156
|
+
style.id = STYLE_ID;
|
|
1157
|
+
style.textContent = CSS;
|
|
1158
|
+
doc.head.append(style);
|
|
1159
|
+
return () => {
|
|
1160
|
+
style.remove();
|
|
1161
|
+
};
|
|
1162
|
+
}
|
|
1163
|
+
//#endregion
|
|
1164
|
+
//#region src/client/ReportsView.tsx
|
|
1165
|
+
/**
|
|
1166
|
+
* The report timeline view — the `conversation.view` tab this plugin registers.
|
|
1167
|
+
*
|
|
1168
|
+
* It renders one top-to-bottom timeline of the collaboration: the session
|
|
1169
|
+
* subtree as it branched, interleaved with the reports those sessions produced
|
|
1170
|
+
* or were copied on. Rows carry only the digest, so a long-running tree stays
|
|
1171
|
+
* scannable; clicking a report opens its transfer path and body on demand.
|
|
1172
|
+
*
|
|
1173
|
+
* Data comes from this plugin's own read-only endpoint rather than a generated
|
|
1174
|
+
* remote: the host assembles the subtree and filters the ledger to the sessions
|
|
1175
|
+
* in it, which the browser cannot do on its own because a report's transfer path
|
|
1176
|
+
* is a host-side file, not session state.
|
|
1177
|
+
*
|
|
1178
|
+
* A blank session never reaches this component — the conversation slot is
|
|
1179
|
+
* omitted entirely in that state.
|
|
1180
|
+
*
|
|
1181
|
+
* @module dsh-report-ledger/client/ReportsView
|
|
1182
|
+
*/
|
|
1183
|
+
/** Storage key for the remembered status filter. */
|
|
1184
|
+
const FILTER_KEY = "dsh.reportLedger.filter.v1";
|
|
1185
|
+
/** Read the remembered status filter, tolerating absent or damaged storage. */
|
|
1186
|
+
function rememberedStatus() {
|
|
1187
|
+
try {
|
|
1188
|
+
const raw = globalThis.localStorage?.getItem(FILTER_KEY);
|
|
1189
|
+
if (typeof raw === "string") {
|
|
1190
|
+
const parsed = JSON.parse(raw);
|
|
1191
|
+
if (parsed.status === "open" || parsed.status === "acked" || parsed.status === "closed") return parsed.status;
|
|
1192
|
+
}
|
|
1193
|
+
} catch {}
|
|
1194
|
+
return "all";
|
|
1195
|
+
}
|
|
1196
|
+
/**
|
|
1197
|
+
* Render the report timeline.
|
|
1198
|
+
* @param props - the session binding and the locale seat.
|
|
1199
|
+
* @returns the tab content.
|
|
1200
|
+
*/
|
|
1201
|
+
function ReportsView(props) {
|
|
1202
|
+
const { sessionId, t } = props;
|
|
1203
|
+
const [payload, setPayload] = (0, react.useState)(void 0);
|
|
1204
|
+
const [error, setError] = (0, react.useState)(void 0);
|
|
1205
|
+
const [timelineNonce, setTimelineNonce] = (0, react.useState)(0);
|
|
1206
|
+
const [detailNonce, setDetailNonce] = (0, react.useState)(0);
|
|
1207
|
+
const [openReport, setOpenReport] = (0, react.useState)(void 0);
|
|
1208
|
+
const [scrollTarget, setScrollTarget] = (0, react.useState)(void 0);
|
|
1209
|
+
/** Row containers, so a thread jump can bring its target on screen. */
|
|
1210
|
+
const rowRefs = (0, react.useRef)(/* @__PURE__ */ new Map());
|
|
1211
|
+
const [status, setStatus] = (0, react.useState)(rememberedStatus);
|
|
1212
|
+
const [text, setText] = (0, react.useState)("");
|
|
1213
|
+
const [basis, setBasis] = (0, react.useState)("created");
|
|
1214
|
+
const { detail, error: detailError } = useReportDetail(openReport, detailNonce);
|
|
1215
|
+
const shown = detail !== void 0 && detail.front.report === openReport ? detail : void 0;
|
|
1216
|
+
(0, react.useEffect)(() => {
|
|
1217
|
+
try {
|
|
1218
|
+
globalThis.localStorage?.setItem(FILTER_KEY, JSON.stringify({ status }));
|
|
1219
|
+
} catch {}
|
|
1220
|
+
}, [status]);
|
|
1221
|
+
(0, react.useEffect)(() => {
|
|
1222
|
+
setPayload(void 0);
|
|
1223
|
+
setError(void 0);
|
|
1224
|
+
setOpenReport(void 0);
|
|
1225
|
+
setScrollTarget(void 0);
|
|
1226
|
+
}, [sessionId]);
|
|
1227
|
+
(0, react.useEffect)(() => {
|
|
1228
|
+
if (sessionId === void 0 || sessionId === "") {
|
|
1229
|
+
setError("no-session");
|
|
1230
|
+
return;
|
|
1231
|
+
}
|
|
1232
|
+
let cancelled = false;
|
|
1233
|
+
getJson(`${TIMELINE_URL}?root=${encodeURIComponent(sessionId)}`).then((value) => {
|
|
1234
|
+
if (!cancelled) {
|
|
1235
|
+
setPayload(value);
|
|
1236
|
+
setError(void 0);
|
|
1237
|
+
}
|
|
1238
|
+
}).catch((cause) => {
|
|
1239
|
+
if (!cancelled) setError(cause instanceof Error ? cause.message : String(cause));
|
|
1240
|
+
});
|
|
1241
|
+
return () => {
|
|
1242
|
+
cancelled = true;
|
|
1243
|
+
};
|
|
1244
|
+
}, [sessionId, timelineNonce]);
|
|
1245
|
+
(0, react.useEffect)(() => {
|
|
1246
|
+
if (sessionId === void 0 || sessionId === "") return void 0;
|
|
1247
|
+
const tick = () => {
|
|
1248
|
+
if (globalThis.document?.visibilityState === "hidden") return;
|
|
1249
|
+
setTimelineNonce((value) => value + 1);
|
|
1250
|
+
};
|
|
1251
|
+
const timer = globalThis.setInterval(tick, POLL_INTERVAL_MS);
|
|
1252
|
+
const onVisibility = () => {
|
|
1253
|
+
if (globalThis.document?.visibilityState !== "hidden") tick();
|
|
1254
|
+
};
|
|
1255
|
+
globalThis.document?.addEventListener("visibilitychange", onVisibility);
|
|
1256
|
+
return () => {
|
|
1257
|
+
globalThis.clearInterval(timer);
|
|
1258
|
+
globalThis.document?.removeEventListener("visibilitychange", onVisibility);
|
|
1259
|
+
};
|
|
1260
|
+
}, [sessionId]);
|
|
1261
|
+
const rows = (0, react.useMemo)(() => payload === void 0 ? [] : buildRows(payload, basis), [payload, basis]);
|
|
1262
|
+
const counts = (0, react.useMemo)(() => statusCounts(payload?.reports ?? []), [payload]);
|
|
1263
|
+
const inList = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Set() : reportIds(payload), [payload]);
|
|
1264
|
+
const lanes = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : laneIndex(payload), [payload]);
|
|
1265
|
+
const depths = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : sessionDepths(payload), [payload]);
|
|
1266
|
+
const filter = (0, react.useMemo)(() => ({
|
|
1267
|
+
status,
|
|
1268
|
+
text
|
|
1269
|
+
}), [status, text]);
|
|
1270
|
+
const visible = (0, react.useMemo)(() => {
|
|
1271
|
+
const kept = filterRows(rows, filter);
|
|
1272
|
+
if (openReport === void 0 || shown === void 0) return kept;
|
|
1273
|
+
return withSynthesizedRow(kept, shown.front, basis, depths.get(shown.front.from) ?? 0);
|
|
1274
|
+
}, [
|
|
1275
|
+
rows,
|
|
1276
|
+
filter,
|
|
1277
|
+
openReport,
|
|
1278
|
+
shown,
|
|
1279
|
+
basis,
|
|
1280
|
+
depths
|
|
1281
|
+
]);
|
|
1282
|
+
(0, react.useEffect)(() => {
|
|
1283
|
+
if (scrollTarget === void 0) return;
|
|
1284
|
+
if (openReport !== scrollTarget) {
|
|
1285
|
+
setScrollTarget(void 0);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const element = rowRefs.current.get(scrollTarget);
|
|
1289
|
+
if (element === void 0) return;
|
|
1290
|
+
setScrollTarget(void 0);
|
|
1291
|
+
if (typeof element.scrollIntoView === "function") element.scrollIntoView({ block: "nearest" });
|
|
1292
|
+
}, [
|
|
1293
|
+
scrollTarget,
|
|
1294
|
+
openReport,
|
|
1295
|
+
visible
|
|
1296
|
+
]);
|
|
1297
|
+
const filteredReportIds = (0, react.useMemo)(() => {
|
|
1298
|
+
const ids = /* @__PURE__ */ new Set();
|
|
1299
|
+
for (const row of filterRows(rows, filter)) if (row.kind === "report") ids.add(row.front.report);
|
|
1300
|
+
return ids;
|
|
1301
|
+
}, [rows, filter]);
|
|
1302
|
+
const filtering = isFiltering(filter);
|
|
1303
|
+
const visibleReportCount = (0, react.useMemo)(() => visibleReports(rows, filter), [rows, filter]);
|
|
1304
|
+
const empty = (0, react.useMemo)(() => payload === void 0 ? "none" : emptyState(payload, rows, filter), [
|
|
1305
|
+
payload,
|
|
1306
|
+
rows,
|
|
1307
|
+
filter
|
|
1308
|
+
]);
|
|
1309
|
+
const titles = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : sessionTitles(payload), [payload]);
|
|
1310
|
+
const idHint = (0, react.useCallback)((id) => {
|
|
1311
|
+
const title = titles.get(id);
|
|
1312
|
+
return title === void 0 ? id : `${title} · ${id}`;
|
|
1313
|
+
}, [titles]);
|
|
1314
|
+
/**
|
|
1315
|
+
* One session id the way a dense row shows it: shortened, with the full value
|
|
1316
|
+
* and the session title on hover. The hover is not decoration — it is where
|
|
1317
|
+
* the human name lives, because titles are unbounded and a row is not.
|
|
1318
|
+
*/
|
|
1319
|
+
const idRef = (id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
1320
|
+
label: idHint(id),
|
|
1321
|
+
side: "bottom",
|
|
1322
|
+
delayMs: 400,
|
|
1323
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: shortId(id) })
|
|
1324
|
+
});
|
|
1325
|
+
const refresh = (0, react.useCallback)(() => {
|
|
1326
|
+
setTimelineNonce((value) => value + 1);
|
|
1327
|
+
setDetailNonce((value) => value + 1);
|
|
1328
|
+
}, []);
|
|
1329
|
+
/**
|
|
1330
|
+
* Open a report that was reached through a link rather than its own row.
|
|
1331
|
+
*
|
|
1332
|
+
* A row click expands in place — the row is already under the pointer and needs
|
|
1333
|
+
* no help. A thread link is different: its target can be off screen, or have no
|
|
1334
|
+
* row at all yet (outside this tree, or hidden by the filter), so the request to
|
|
1335
|
+
* bring it on screen is recorded here and satisfied by the effect above when
|
|
1336
|
+
* the row finally mounts.
|
|
1337
|
+
*/
|
|
1338
|
+
const openLinked = (0, react.useCallback)((report) => {
|
|
1339
|
+
setOpenReport(report);
|
|
1340
|
+
setScrollTarget(report);
|
|
1341
|
+
}, []);
|
|
1342
|
+
const shell = {
|
|
1343
|
+
padding: "16px 20px",
|
|
1344
|
+
overflowY: "auto",
|
|
1345
|
+
height: "100%",
|
|
1346
|
+
font: "var(--dsw-font-xs-13, 13px/1.6 system-ui, sans-serif)",
|
|
1347
|
+
color: "var(--dsw-alias-label-primary, inherit)"
|
|
1348
|
+
};
|
|
1349
|
+
const head = {
|
|
1350
|
+
display: "flex",
|
|
1351
|
+
alignItems: "baseline",
|
|
1352
|
+
gap: "12px",
|
|
1353
|
+
marginBottom: "12px"
|
|
1354
|
+
};
|
|
1355
|
+
const summary = {
|
|
1356
|
+
color: "var(--dsw-alias-label-tertiary, #888)",
|
|
1357
|
+
fontSize: "var(--dsw-font-xxs-12, 12)",
|
|
1358
|
+
flex: "1 1 auto"
|
|
1359
|
+
};
|
|
1360
|
+
/**
|
|
1361
|
+
* The toolbar's search seat.
|
|
1362
|
+
*
|
|
1363
|
+
* `Input` renders its own wrapper around the field, so the flex sizing lives
|
|
1364
|
+
* on this outer box rather than on the control itself.
|
|
1365
|
+
*/
|
|
1366
|
+
const searchBox = {
|
|
1367
|
+
flex: "1 1 200px",
|
|
1368
|
+
minWidth: "140px",
|
|
1369
|
+
display: "flex"
|
|
1370
|
+
};
|
|
1371
|
+
const tools = {
|
|
1372
|
+
display: "flex",
|
|
1373
|
+
alignItems: "center",
|
|
1374
|
+
flexWrap: "wrap",
|
|
1375
|
+
gap: "6px",
|
|
1376
|
+
marginBottom: "10px"
|
|
1377
|
+
};
|
|
1378
|
+
/** Chips sit next to a label; the shell's tags carry no spacing of their own. */
|
|
1379
|
+
const tagRow = {
|
|
1380
|
+
display: "inline-flex",
|
|
1381
|
+
gap: "4px",
|
|
1382
|
+
marginRight: "6px",
|
|
1383
|
+
verticalAlign: "baseline"
|
|
1384
|
+
};
|
|
1385
|
+
/** Keeps the state dot on the row's text baseline. */
|
|
1386
|
+
const dotCell = {
|
|
1387
|
+
display: "inline-flex",
|
|
1388
|
+
alignItems: "center"
|
|
1389
|
+
};
|
|
1390
|
+
/** The caption look for a timestamp; the digest's meta line reuses its colour. */
|
|
1391
|
+
const time = {
|
|
1392
|
+
color: "var(--dsw-alias-label-caption, #999)",
|
|
1393
|
+
fontSize: "var(--dsw-font-xxxs-11, 11)",
|
|
1394
|
+
whiteSpace: "nowrap"
|
|
1395
|
+
};
|
|
1396
|
+
/** The muted caption colour, for the empty-state sentences. */
|
|
1397
|
+
const muted = { color: "var(--dsw-alias-label-tertiary, #888)" };
|
|
1398
|
+
/**
|
|
1399
|
+
* The strip that reports a failed re-read WITHOUT replacing the list.
|
|
1400
|
+
*
|
|
1401
|
+
* A warning, not an error: the ledger on screen is still the last thing that was
|
|
1402
|
+
* successfully read, and the poll keeps trying. Colouring it like a page failure
|
|
1403
|
+
* would teach the reader to stop believing the colour. The token is the shell's
|
|
1404
|
+
* warn label rather than a mixed colour, so it follows the theme.
|
|
1405
|
+
*/
|
|
1406
|
+
const notice = {
|
|
1407
|
+
display: "flex",
|
|
1408
|
+
alignItems: "center",
|
|
1409
|
+
gap: "6px",
|
|
1410
|
+
marginBottom: "10px",
|
|
1411
|
+
color: "var(--dsw-alias-state-warn-label, #a60)"
|
|
1412
|
+
};
|
|
1413
|
+
if (payload === void 0 && error !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1414
|
+
style: shell,
|
|
1415
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1416
|
+
style: {
|
|
1417
|
+
display: "flex",
|
|
1418
|
+
alignItems: "center",
|
|
1419
|
+
gap: "6px"
|
|
1420
|
+
},
|
|
1421
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
1422
|
+
state: "error",
|
|
1423
|
+
size: 8
|
|
1424
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("view.error", { code: error }) })]
|
|
1425
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1426
|
+
style: { marginTop: "8px" },
|
|
1427
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1428
|
+
variant: "outline",
|
|
1429
|
+
onClick: refresh,
|
|
1430
|
+
children: t("view.retry")
|
|
1431
|
+
})
|
|
1432
|
+
})]
|
|
1433
|
+
});
|
|
1434
|
+
if (payload === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1435
|
+
style: shell,
|
|
1436
|
+
children: t("view.loading")
|
|
1437
|
+
});
|
|
1438
|
+
const statusLabel = {
|
|
1439
|
+
all: "filter.all",
|
|
1440
|
+
open: "filter.open",
|
|
1441
|
+
acked: "filter.acked",
|
|
1442
|
+
closed: "filter.closed"
|
|
1443
|
+
};
|
|
1444
|
+
const statusCount = {
|
|
1445
|
+
all: counts.all,
|
|
1446
|
+
open: counts.open,
|
|
1447
|
+
acked: counts.acked,
|
|
1448
|
+
closed: counts.closed
|
|
1449
|
+
};
|
|
1450
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1451
|
+
style: shell,
|
|
1452
|
+
children: [
|
|
1453
|
+
error === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1454
|
+
style: notice,
|
|
1455
|
+
children: [
|
|
1456
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
1457
|
+
state: "warning",
|
|
1458
|
+
size: 8
|
|
1459
|
+
}),
|
|
1460
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1461
|
+
style: { flex: "1 1 auto" },
|
|
1462
|
+
children: t("view.error", { code: error })
|
|
1463
|
+
}),
|
|
1464
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1465
|
+
variant: "outline",
|
|
1466
|
+
onClick: refresh,
|
|
1467
|
+
children: t("view.retry")
|
|
1468
|
+
})
|
|
1469
|
+
]
|
|
1470
|
+
}),
|
|
1471
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1472
|
+
style: head,
|
|
1473
|
+
children: [
|
|
1474
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.tab") }),
|
|
1475
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1476
|
+
style: summary,
|
|
1477
|
+
children: filtering ? t("filter.showing", {
|
|
1478
|
+
visible: visibleReportCount,
|
|
1479
|
+
total: counts.all
|
|
1480
|
+
}) : t("view.summary", {
|
|
1481
|
+
sessions: payload.sessions.length,
|
|
1482
|
+
reports: payload.reports.length
|
|
1483
|
+
})
|
|
1484
|
+
}),
|
|
1485
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1486
|
+
variant: "outline",
|
|
1487
|
+
icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, { size: 16 }),
|
|
1488
|
+
onClick: refresh,
|
|
1489
|
+
children: t("view.refresh")
|
|
1490
|
+
})
|
|
1491
|
+
]
|
|
1492
|
+
}),
|
|
1493
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1494
|
+
style: tools,
|
|
1495
|
+
title: t("filter.hint"),
|
|
1496
|
+
children: [
|
|
1497
|
+
[
|
|
1498
|
+
"all",
|
|
1499
|
+
"open",
|
|
1500
|
+
"acked",
|
|
1501
|
+
"closed"
|
|
1502
|
+
].map((candidate) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
1503
|
+
active: status === candidate,
|
|
1504
|
+
"aria-pressed": status === candidate,
|
|
1505
|
+
onClick: () => {
|
|
1506
|
+
setStatus(candidate);
|
|
1507
|
+
},
|
|
1508
|
+
children: `${t(statusLabel[candidate])} ${statusCount[candidate]}`
|
|
1509
|
+
}, candidate)),
|
|
1510
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1511
|
+
style: searchBox,
|
|
1512
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
|
|
1513
|
+
type: "search",
|
|
1514
|
+
className: SEARCH_CLASS,
|
|
1515
|
+
value: text,
|
|
1516
|
+
placeholder: t("filter.search"),
|
|
1517
|
+
"aria-label": t("filter.search"),
|
|
1518
|
+
onChange: (event) => {
|
|
1519
|
+
setText(event.target.value);
|
|
1520
|
+
}
|
|
1521
|
+
})
|
|
1522
|
+
}),
|
|
1523
|
+
filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1524
|
+
variant: "outline",
|
|
1525
|
+
onClick: () => {
|
|
1526
|
+
setStatus("all");
|
|
1527
|
+
setText("");
|
|
1528
|
+
},
|
|
1529
|
+
children: t("filter.clear")
|
|
1530
|
+
}) : null,
|
|
1531
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
1532
|
+
label: t("basis.hint"),
|
|
1533
|
+
side: "bottom",
|
|
1534
|
+
delayMs: 400,
|
|
1535
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1536
|
+
variant: "outline",
|
|
1537
|
+
onClick: () => {
|
|
1538
|
+
setBasis(basis === "created" ? "updated" : "created");
|
|
1539
|
+
},
|
|
1540
|
+
children: t(basis === "created" ? "basis.created" : "basis.updated")
|
|
1541
|
+
})
|
|
1542
|
+
})
|
|
1543
|
+
]
|
|
1544
|
+
}),
|
|
1545
|
+
empty === "no-reports" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1546
|
+
style: muted,
|
|
1547
|
+
children: t("view.empty")
|
|
1548
|
+
}) : null,
|
|
1549
|
+
empty === "filtered-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
1550
|
+
style: muted,
|
|
1551
|
+
children: t("filter.none")
|
|
1552
|
+
}) : null,
|
|
1553
|
+
visible.map((row) => {
|
|
1554
|
+
const pad = 8 + row.depth * 18;
|
|
1555
|
+
if (row.kind === "session") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1556
|
+
style: {
|
|
1557
|
+
display: "flex",
|
|
1558
|
+
gap: "8px",
|
|
1559
|
+
padding: `4px 0 4px ${pad}px`,
|
|
1560
|
+
alignItems: "baseline"
|
|
1561
|
+
},
|
|
1562
|
+
children: [
|
|
1563
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
1564
|
+
label: idHint(row.id),
|
|
1565
|
+
side: "bottom",
|
|
1566
|
+
delayMs: 400,
|
|
1567
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1568
|
+
style: dotCell,
|
|
1569
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
|
|
1570
|
+
state: row.live ? "ongoing" : "idle",
|
|
1571
|
+
size: 8
|
|
1572
|
+
})
|
|
1573
|
+
})
|
|
1574
|
+
}),
|
|
1575
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1576
|
+
style: {
|
|
1577
|
+
flex: "1 1 auto",
|
|
1578
|
+
minWidth: 0
|
|
1579
|
+
},
|
|
1580
|
+
children: [row.delegated || row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1581
|
+
style: tagRow,
|
|
1582
|
+
children: [row.delegated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
1583
|
+
tone: "info",
|
|
1584
|
+
children: t("view.delegated")
|
|
1585
|
+
}) : null, row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
1586
|
+
tone: "success",
|
|
1587
|
+
children: t("view.live")
|
|
1588
|
+
}) : null]
|
|
1589
|
+
}) : null, row.title ?? shortId(row.id)]
|
|
1590
|
+
}),
|
|
1591
|
+
row.title === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
|
|
1592
|
+
label: row.id,
|
|
1593
|
+
side: "right",
|
|
1594
|
+
delayMs: 400,
|
|
1595
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1596
|
+
style: time,
|
|
1597
|
+
children: shortId(row.id)
|
|
1598
|
+
})
|
|
1599
|
+
}),
|
|
1600
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1601
|
+
style: time,
|
|
1602
|
+
children: stamp(row.at)
|
|
1603
|
+
})
|
|
1604
|
+
]
|
|
1605
|
+
}, `s-${row.id}`);
|
|
1606
|
+
const front = row.front;
|
|
1607
|
+
const expanded = openReport === front.report;
|
|
1608
|
+
const panelId = `report-ledger-panel-${front.report}`;
|
|
1609
|
+
const wtLabel = worktreeLabel(front.worktree);
|
|
1610
|
+
const wtLane = lanes.get(front.report);
|
|
1611
|
+
const toggleLabel = fill(expanded ? t("row.collapse") : t("row.expand"), { report: front.report });
|
|
1612
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1613
|
+
ref: (element) => {
|
|
1614
|
+
if (element === null) rowRefs.current.delete(front.report);
|
|
1615
|
+
else rowRefs.current.set(front.report, element);
|
|
1616
|
+
},
|
|
1617
|
+
style: { paddingLeft: `${pad}px` },
|
|
1618
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1619
|
+
onClick: () => {
|
|
1620
|
+
setOpenReport(expanded ? void 0 : front.report);
|
|
1621
|
+
},
|
|
1622
|
+
style: {
|
|
1623
|
+
display: "flex",
|
|
1624
|
+
gap: "8px",
|
|
1625
|
+
alignItems: "baseline",
|
|
1626
|
+
padding: "5px 8px",
|
|
1627
|
+
margin: "2px 0",
|
|
1628
|
+
borderRadius: "6px",
|
|
1629
|
+
cursor: "pointer",
|
|
1630
|
+
background: "var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.02))",
|
|
1631
|
+
border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
|
|
1632
|
+
},
|
|
1633
|
+
children: [
|
|
1634
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
1635
|
+
variant: "ghost",
|
|
1636
|
+
size: "sm",
|
|
1637
|
+
icon: expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronDownOutlineRegular, { size: 14 }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconChevronRightOutlineRegular, { size: 14 }),
|
|
1638
|
+
"aria-expanded": expanded,
|
|
1639
|
+
...expanded ? { "aria-controls": panelId } : {},
|
|
1640
|
+
"aria-label": toggleLabel,
|
|
1641
|
+
title: toggleLabel,
|
|
1642
|
+
onClick: (event) => {
|
|
1643
|
+
event.stopPropagation();
|
|
1644
|
+
setOpenReport(expanded ? void 0 : front.report);
|
|
1645
|
+
}
|
|
1646
|
+
}),
|
|
1647
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
1648
|
+
style: {
|
|
1649
|
+
flex: "1 1 auto",
|
|
1650
|
+
minWidth: 0
|
|
1651
|
+
},
|
|
1652
|
+
children: [
|
|
1653
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1654
|
+
style: tagRow,
|
|
1655
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
1656
|
+
tone: STATUS_TONE[front.status],
|
|
1657
|
+
children: t(STATUS_LABEL[front.status])
|
|
1658
|
+
})
|
|
1659
|
+
}),
|
|
1660
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: front.report }),
|
|
1661
|
+
" ",
|
|
1662
|
+
front.subject,
|
|
1663
|
+
front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
|
|
1664
|
+
title: fill(t("row.filterByTask"), { task: front.task }),
|
|
1665
|
+
onClick: (event) => {
|
|
1666
|
+
event.stopPropagation();
|
|
1667
|
+
setStatus("all");
|
|
1668
|
+
setText(front.task);
|
|
1669
|
+
},
|
|
1670
|
+
children: front.task
|
|
1671
|
+
}),
|
|
1672
|
+
wtLabel === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
|
|
1673
|
+
tone: LEASE_TONE[wtLane?.state ?? "none"],
|
|
1674
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1675
|
+
title: `${fill(t("row.filterByWorktree"), { worktree: wtLabel })} — ${t(LEASE_HINT[wtLane?.state ?? "none"])}`,
|
|
1676
|
+
style: { cursor: "pointer" },
|
|
1677
|
+
onClick: (event) => {
|
|
1678
|
+
event.stopPropagation();
|
|
1679
|
+
setStatus("all");
|
|
1680
|
+
setText(wtLabel);
|
|
1681
|
+
},
|
|
1682
|
+
children: wtLabel
|
|
1683
|
+
})
|
|
1684
|
+
}),
|
|
1685
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
1686
|
+
style: {
|
|
1687
|
+
...time,
|
|
1688
|
+
marginTop: "2px",
|
|
1689
|
+
overflow: "hidden",
|
|
1690
|
+
textOverflow: "ellipsis"
|
|
1691
|
+
},
|
|
1692
|
+
children: [
|
|
1693
|
+
`from=`,
|
|
1694
|
+
idRef(front.from),
|
|
1695
|
+
front.to.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" → ", front.to.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [index > 0 ? ", " : "", idRef(id)] }, id))] }) : null,
|
|
1696
|
+
front.cc.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [" cc ", front.cc.map((id, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react.Fragment, { children: [index > 0 ? ", " : "", idRef(id)] }, id))] }) : null,
|
|
1697
|
+
` · ${fill(t("row.hops"), { count: front.hops })}`,
|
|
1698
|
+
front.authors.length > 1 ? ` · ${fill(t("row.authors"), { count: front.authors.length })}` : "",
|
|
1699
|
+
front.last === void 0 ? "" : ` · ${t("row.last")} ${t(HOP_LABEL[front.last.action])}`
|
|
1700
|
+
]
|
|
1701
|
+
})
|
|
1702
|
+
]
|
|
1703
|
+
}),
|
|
1704
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
1705
|
+
style: time,
|
|
1706
|
+
children: stamp(row.at)
|
|
1707
|
+
})
|
|
1708
|
+
]
|
|
1709
|
+
}), expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReportDetailView, {
|
|
1710
|
+
report: front.report,
|
|
1711
|
+
detail: shown,
|
|
1712
|
+
error: detailError,
|
|
1713
|
+
t,
|
|
1714
|
+
idHint,
|
|
1715
|
+
id: panelId,
|
|
1716
|
+
variant: "row",
|
|
1717
|
+
onOpenReport: openLinked,
|
|
1718
|
+
inList: inList.has(front.report),
|
|
1719
|
+
visibleInFilter: filteredReportIds.has(front.report)
|
|
1720
|
+
}) : null]
|
|
1721
|
+
}, `r-${front.report}`);
|
|
1722
|
+
})
|
|
1723
|
+
]
|
|
1724
|
+
});
|
|
1725
|
+
}
|
|
1726
|
+
//#endregion
|
|
1727
|
+
//#region src/client/cardgraph-model.ts
|
|
1728
|
+
/**
|
|
1729
|
+
* Defaults.
|
|
1730
|
+
*
|
|
1731
|
+
* A full card is 240x110 — enough for a title, a status line and two lines of
|
|
1732
|
+
* subject at a readable size. The chip tier is deliberately tiny: below 0.4 zoom
|
|
1733
|
+
* a card is a colour, and drawing readable text there would be a lie about how
|
|
1734
|
+
* much of the drawing the reader can actually take in.
|
|
1735
|
+
*/
|
|
1736
|
+
const DEFAULT_CARDGRAPH_OPTIONS = {
|
|
1737
|
+
lod: "card",
|
|
1738
|
+
cardMinZoom: .8,
|
|
1739
|
+
compactMinZoom: .4,
|
|
1740
|
+
sizes: {
|
|
1741
|
+
card: {
|
|
1742
|
+
width: 240,
|
|
1743
|
+
height: 110
|
|
1744
|
+
},
|
|
1745
|
+
compact: {
|
|
1746
|
+
width: 160,
|
|
1747
|
+
height: 36
|
|
1748
|
+
},
|
|
1749
|
+
chip: {
|
|
1750
|
+
width: 60,
|
|
1751
|
+
height: 14
|
|
1752
|
+
}
|
|
1753
|
+
},
|
|
1754
|
+
frameHeaderHeight: 26,
|
|
1755
|
+
framePaddingX: 12,
|
|
1756
|
+
framePaddingTop: 10,
|
|
1757
|
+
framePaddingBottom: 12,
|
|
1758
|
+
minFrameWidth: 180,
|
|
1759
|
+
minFrameHeight: 96,
|
|
1760
|
+
cardGap: 8,
|
|
1761
|
+
columnGap: 48,
|
|
1762
|
+
frameGap: 24,
|
|
1763
|
+
margin: 24
|
|
1764
|
+
};
|
|
1765
|
+
/** How far out the drawing may be zoomed, in screen pixels per drawing unit. */
|
|
1766
|
+
const MIN_ZOOM = .25;
|
|
1767
|
+
/** How far in the drawing may be zoomed. */
|
|
1768
|
+
const MAX_ZOOM = 2.5;
|
|
1769
|
+
/** One step of the zoom controls, in screen pixels per drawing unit. */
|
|
1770
|
+
const ZOOM_STEP = 1.25;
|
|
1771
|
+
/**
|
|
1772
|
+
* The most a fit may magnify.
|
|
1773
|
+
*
|
|
1774
|
+
* Filling the width on a drawing that holds one card would otherwise ask for
|
|
1775
|
+
* several times the design's size: one card stretched across the whole box,
|
|
1776
|
+
* larger than any reading needs and carrying no more in it, because a tier is
|
|
1777
|
+
* drawn at a fixed size and zooming past 100% only magnifies. One control step
|
|
1778
|
+
* is the ceiling, so the fit may lean in but never stretch.
|
|
1779
|
+
*/
|
|
1780
|
+
const MAX_FIT_ZOOM = ZOOM_STEP;
|
|
1781
|
+
/**
|
|
1782
|
+
* Which tier a zoom level calls for.
|
|
1783
|
+
*
|
|
1784
|
+
* Thresholds are inclusive on the low end, so 0.8 is a full card and 0.4 is a
|
|
1785
|
+
* compact one: a zoom that lands exactly on a step reads as the richer of the
|
|
1786
|
+
* two. A zoom that is not a usable number degrades to the cheapest tier instead
|
|
1787
|
+
* of producing an infinite or negative box.
|
|
1788
|
+
* @param zoom - the current scale.
|
|
1789
|
+
* @param options - threshold overrides.
|
|
1790
|
+
* @returns the tier to lay out at.
|
|
1791
|
+
*/
|
|
1792
|
+
function lodFor(zoom, options = {}) {
|
|
1793
|
+
const opts = {
|
|
1794
|
+
...DEFAULT_CARDGRAPH_OPTIONS,
|
|
1795
|
+
...options
|
|
1796
|
+
};
|
|
1797
|
+
if (!Number.isFinite(zoom)) return "chip";
|
|
1798
|
+
if (zoom >= opts.cardMinZoom) return "card";
|
|
1799
|
+
if (zoom >= opts.compactMinZoom) return "compact";
|
|
1800
|
+
return "chip";
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* The three tiers in the order a zoom sweep visits them, richest first.
|
|
1804
|
+
*
|
|
1805
|
+
* Exported so a painter can pre-build its text caches per tier without hardcoding
|
|
1806
|
+
* the list in a second place.
|
|
1807
|
+
*/
|
|
1808
|
+
const CARDGRAPH_LODS = [
|
|
1809
|
+
"card",
|
|
1810
|
+
"compact",
|
|
1811
|
+
"chip"
|
|
1812
|
+
];
|
|
1813
|
+
/** Whether two rectangles overlap. Touching edges do not count as overlapping. */
|
|
1814
|
+
function intersects(a, b) {
|
|
1815
|
+
return a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
|
|
1816
|
+
}
|
|
1817
|
+
/** Whether a point is inside a rectangle. The top-left edges are inside, the bottom-right ones are not. */
|
|
1818
|
+
function contains(rect, x, y) {
|
|
1819
|
+
return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
|
|
1820
|
+
}
|
|
1821
|
+
/** Grow a rectangle by `by` on every side. */
|
|
1822
|
+
function grow(rect, by) {
|
|
1823
|
+
return {
|
|
1824
|
+
x: rect.x - by,
|
|
1825
|
+
y: rect.y - by,
|
|
1826
|
+
width: rect.width + by * 2,
|
|
1827
|
+
height: rect.height + by * 2
|
|
1828
|
+
};
|
|
1829
|
+
}
|
|
1830
|
+
/**
|
|
1831
|
+
* The topmost item under a point.
|
|
1832
|
+
*
|
|
1833
|
+
* Free of the DOM on purpose: the canvas has no elements to hit, so picking is a
|
|
1834
|
+
* function from a point and an ordered list to an item — and that function is
|
|
1835
|
+
* testable here rather than only by clicking in a browser. The caller passes the
|
|
1836
|
+
* list **topmost first**, which is what makes "a card wins over the frame holding
|
|
1837
|
+
* it" a property of the order rather than of the arithmetic.
|
|
1838
|
+
* @param items - candidates, topmost first.
|
|
1839
|
+
* @param x - point, in drawing coordinates.
|
|
1840
|
+
* @param y - point, in drawing coordinates.
|
|
1841
|
+
* @returns the first hit, or `undefined`.
|
|
1842
|
+
*/
|
|
1843
|
+
function hitTest(items, x, y) {
|
|
1844
|
+
return items.find((item) => contains(item.bounds, x, y));
|
|
1845
|
+
}
|
|
1846
|
+
/**
|
|
1847
|
+
* The part of the drawing a viewport can see, in drawing coordinates.
|
|
1848
|
+
*
|
|
1849
|
+
* The inverse of the transform the painter applies, and the input to culling. A
|
|
1850
|
+
* broken transform is treated as identity rather than propagated: a scale of zero
|
|
1851
|
+
* or a NaN would otherwise turn the visible rect into an infinite or reversed box
|
|
1852
|
+
* and either cull everything or draw everything.
|
|
1853
|
+
* @param view - the transform and the size of the paint box.
|
|
1854
|
+
* @param overscan - drawing units to keep on each side, so a pan does not reveal a
|
|
1855
|
+
* blank band before the next frame is painted.
|
|
1856
|
+
* @returns the visible rect.
|
|
1857
|
+
*/
|
|
1858
|
+
function worldViewport(view, overscan = 0) {
|
|
1859
|
+
const scale = Number.isFinite(view.scale) && view.scale > 0 ? view.scale : 1;
|
|
1860
|
+
const width = Number.isFinite(view.width) ? Math.max(0, view.width) : 0;
|
|
1861
|
+
const height = Number.isFinite(view.height) ? Math.max(0, view.height) : 0;
|
|
1862
|
+
const offsetX = Number.isFinite(view.offsetX) ? view.offsetX : 0;
|
|
1863
|
+
const offsetY = Number.isFinite(view.offsetY) ? view.offsetY : 0;
|
|
1864
|
+
const rect = {
|
|
1865
|
+
x: -offsetX / scale,
|
|
1866
|
+
y: -offsetY / scale,
|
|
1867
|
+
width: width / scale,
|
|
1868
|
+
height: height / scale
|
|
1869
|
+
};
|
|
1870
|
+
return overscan === 0 ? rect : grow(rect, overscan);
|
|
1871
|
+
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Build the card graph.
|
|
1874
|
+
*
|
|
1875
|
+
* Frames come from the payload's sessions: column by subtree depth, vertical
|
|
1876
|
+
* order by the payload's own order (the DFS pre-order the list indents by), so a
|
|
1877
|
+
* session keeps its relative position across the two views. Cards are stacked
|
|
1878
|
+
* inside their author's frame **by time**, because a frame is one session's own
|
|
1879
|
+
* timeline; the reader who wants one timeline across all sessions has the list
|
|
1880
|
+
* below. Only the reports handed in are drawn, so this is a filtered view rather
|
|
1881
|
+
* than a second source of truth.
|
|
1882
|
+
* @param payload - the endpoint payload, for the session frames.
|
|
1883
|
+
* @param reports - the reports to plot; any order, since each frame sorts its own.
|
|
1884
|
+
* @param options - geometry and tier overrides.
|
|
1885
|
+
* @returns the layout; empty (and zero-sized) when there is nothing to draw.
|
|
1886
|
+
*/
|
|
1887
|
+
function buildCardgraph(payload, reports, options = {}) {
|
|
1888
|
+
const opts = {
|
|
1889
|
+
...DEFAULT_CARDGRAPH_OPTIONS,
|
|
1890
|
+
...options
|
|
1891
|
+
};
|
|
1892
|
+
const size = opts.sizes[opts.lod];
|
|
1893
|
+
const frameWidth = Math.max(opts.minFrameWidth, size.width + opts.framePaddingX * 2);
|
|
1894
|
+
const sessions = payload.sessions;
|
|
1895
|
+
const depths = [...new Set(sessions.map((node) => node.depth))].sort((a, b) => a - b);
|
|
1896
|
+
const columnOfDepth = /* @__PURE__ */ new Map();
|
|
1897
|
+
for (const [index, depth] of depths.entries()) columnOfDepth.set(depth, index);
|
|
1898
|
+
const frameOf = /* @__PURE__ */ new Map();
|
|
1899
|
+
const frames = sessions.map((node, index) => {
|
|
1900
|
+
frameOf.set(node.id, index);
|
|
1901
|
+
return {
|
|
1902
|
+
...node.title === void 0 ? {} : { title: node.title },
|
|
1903
|
+
id: node.id,
|
|
1904
|
+
shortId: shortId(node.id),
|
|
1905
|
+
depth: node.depth,
|
|
1906
|
+
column: columnOfDepth.get(node.depth) ?? 0,
|
|
1907
|
+
live: node.live,
|
|
1908
|
+
external: false,
|
|
1909
|
+
cards: 0
|
|
1910
|
+
};
|
|
1911
|
+
});
|
|
1912
|
+
const mentions = (front) => [
|
|
1913
|
+
front.from,
|
|
1914
|
+
...front.to,
|
|
1915
|
+
...front.cc,
|
|
1916
|
+
...front.authors
|
|
1917
|
+
];
|
|
1918
|
+
const needsExternal = reports.some((front) => mentions(front).some((id) => id !== void 0 && !frameOf.has(id)));
|
|
1919
|
+
const externalDepth = sessions.reduce((max, node) => Math.max(max, node.depth), -1) + 1;
|
|
1920
|
+
if (needsExternal) frames.push({
|
|
1921
|
+
shortId: "",
|
|
1922
|
+
depth: externalDepth,
|
|
1923
|
+
column: depths.length,
|
|
1924
|
+
live: false,
|
|
1925
|
+
external: true,
|
|
1926
|
+
cards: 0
|
|
1927
|
+
});
|
|
1928
|
+
const externalIndex = needsExternal ? frames.length - 1 : -1;
|
|
1929
|
+
const frameIndexById = (id) => id !== void 0 && frameOf.has(id) ? frameOf.get(id) : externalIndex;
|
|
1930
|
+
const buckets = frames.map(() => []);
|
|
1931
|
+
for (const front of reports) {
|
|
1932
|
+
const target = frameIndexById(front.from);
|
|
1933
|
+
if (target >= 0) buckets[target]?.push(front);
|
|
1934
|
+
}
|
|
1935
|
+
for (const bucket of buckets) bucket.sort((a, b) => a.created - b.created || (a.report < b.report ? -1 : a.report > b.report ? 1 : 0));
|
|
1936
|
+
const columnX = (column) => opts.margin + column * (frameWidth + opts.columnGap);
|
|
1937
|
+
const frameHeight = (count) => Math.max(opts.minFrameHeight, opts.frameHeaderHeight + opts.framePaddingTop + opts.framePaddingBottom + count * size.height + Math.max(0, count - 1) * opts.cardGap);
|
|
1938
|
+
frames.forEach((frame, index) => {
|
|
1939
|
+
frame.cards = buckets[index]?.length ?? 0;
|
|
1940
|
+
});
|
|
1941
|
+
const cursor = /* @__PURE__ */ new Map();
|
|
1942
|
+
const fixed = frames.map((frame) => {
|
|
1943
|
+
const height = frameHeight(frame.cards);
|
|
1944
|
+
const y = cursor.get(frame.column) ?? opts.margin;
|
|
1945
|
+
cursor.set(frame.column, y + height + opts.frameGap);
|
|
1946
|
+
return {
|
|
1947
|
+
x: columnX(frame.column),
|
|
1948
|
+
y,
|
|
1949
|
+
width: frameWidth,
|
|
1950
|
+
height
|
|
1951
|
+
};
|
|
1952
|
+
});
|
|
1953
|
+
const columnCount = depths.length + (needsExternal ? 1 : 0);
|
|
1954
|
+
const heights = Array.from({ length: columnCount }, (_unused, column) => {
|
|
1955
|
+
const stacked = fixed.filter((_box, index) => frames[index]?.column === column);
|
|
1956
|
+
return stacked.length === 0 ? 0 : stacked.reduce((sum, box) => sum + box.height, 0) + (stacked.length - 1) * opts.frameGap;
|
|
1957
|
+
});
|
|
1958
|
+
const layoutFrames = frames.map((frame, index) => ({
|
|
1959
|
+
...frame.id === void 0 ? {} : { id: frame.id },
|
|
1960
|
+
...frame.title === void 0 ? {} : { title: frame.title },
|
|
1961
|
+
index,
|
|
1962
|
+
shortId: frame.shortId,
|
|
1963
|
+
depth: frame.depth,
|
|
1964
|
+
column: frame.column,
|
|
1965
|
+
live: frame.live,
|
|
1966
|
+
external: frame.external,
|
|
1967
|
+
cards: frame.cards,
|
|
1968
|
+
bounds: fixed[index]
|
|
1969
|
+
}));
|
|
1970
|
+
const columns = Array.from({ length: columnCount }, (_unused, column) => ({
|
|
1971
|
+
depth: depths[column] ?? externalDepth,
|
|
1972
|
+
index: column,
|
|
1973
|
+
bounds: {
|
|
1974
|
+
x: columnX(column),
|
|
1975
|
+
y: opts.margin,
|
|
1976
|
+
width: frameWidth,
|
|
1977
|
+
height: Math.max(0, heights[column] ?? 0)
|
|
1978
|
+
}
|
|
1979
|
+
}));
|
|
1980
|
+
const cards = [];
|
|
1981
|
+
const cardIndexOf = /* @__PURE__ */ new Map();
|
|
1982
|
+
layoutFrames.forEach((frame) => {
|
|
1983
|
+
(buckets[frame.index] ?? []).forEach((front, slot) => {
|
|
1984
|
+
cardIndexOf.set(front.report, cards.length);
|
|
1985
|
+
cards.push({
|
|
1986
|
+
report: front.report,
|
|
1987
|
+
subject: front.subject,
|
|
1988
|
+
status: front.status,
|
|
1989
|
+
...front.task === void 0 ? {} : { task: front.task },
|
|
1990
|
+
frame: frame.index,
|
|
1991
|
+
column: frame.column,
|
|
1992
|
+
slot,
|
|
1993
|
+
lod: opts.lod,
|
|
1994
|
+
bounds: {
|
|
1995
|
+
x: frame.bounds.x + opts.framePaddingX,
|
|
1996
|
+
y: frame.bounds.y + opts.frameHeaderHeight + opts.framePaddingTop + slot * (size.height + opts.cardGap),
|
|
1997
|
+
width: size.width,
|
|
1998
|
+
height: size.height
|
|
1999
|
+
}
|
|
2000
|
+
});
|
|
2001
|
+
});
|
|
2002
|
+
});
|
|
2003
|
+
const cardEnd = (index, side) => {
|
|
2004
|
+
const box = cards[index]?.bounds ?? {
|
|
2005
|
+
x: 0,
|
|
2006
|
+
y: 0,
|
|
2007
|
+
width: 0,
|
|
2008
|
+
height: 0
|
|
2009
|
+
};
|
|
2010
|
+
return {
|
|
2011
|
+
kind: "card",
|
|
2012
|
+
id: cards[index]?.report,
|
|
2013
|
+
index,
|
|
2014
|
+
side,
|
|
2015
|
+
x: side === "right" ? box.x + box.width : side === "left" ? box.x : box.x + box.width / 2,
|
|
2016
|
+
y: side === "bottom" ? box.y + box.height : side === "top" ? box.y : box.y + box.height / 2
|
|
2017
|
+
};
|
|
2018
|
+
};
|
|
2019
|
+
const frameEnd = (index, side) => {
|
|
2020
|
+
const box = fixed[index] ?? {
|
|
2021
|
+
x: 0,
|
|
2022
|
+
y: 0,
|
|
2023
|
+
width: 0,
|
|
2024
|
+
height: 0
|
|
2025
|
+
};
|
|
2026
|
+
return {
|
|
2027
|
+
kind: "frame",
|
|
2028
|
+
...frames[index]?.id === void 0 ? {} : { id: frames[index]?.id },
|
|
2029
|
+
index,
|
|
2030
|
+
side,
|
|
2031
|
+
x: side === "right" ? box.x + box.width : box.x,
|
|
2032
|
+
y: box.y + box.height / 2
|
|
2033
|
+
};
|
|
2034
|
+
};
|
|
2035
|
+
const externalBetween = (a, b) => externalIndex >= 0 && (a === externalIndex || b === externalIndex) || false;
|
|
2036
|
+
const pending = [];
|
|
2037
|
+
/**
|
|
2038
|
+
* The side a wire should bend through to reach another frame without crossing
|
|
2039
|
+
* one.
|
|
2040
|
+
*
|
|
2041
|
+
* Two sessions in the **same column** — siblings, or any two frames at one
|
|
2042
|
+
* depth — cannot be reached left-to-right: a straight line from the sender's
|
|
2043
|
+
* card to the recipient's facing edge runs back across the recipient's own box,
|
|
2044
|
+
* which reads as "this wire belongs to that frame". Such a wire bends down the
|
|
2045
|
+
* gutter beside the column instead: out of the card, along the gutter, into the
|
|
2046
|
+
* frame's edge on that same side. The gutter is on the right except in the last
|
|
2047
|
+
* column, where that would run along the outside of the drawing.
|
|
2048
|
+
*/
|
|
2049
|
+
const gutterSide = (column) => column < columns.length - 1 ? "right" : "left";
|
|
2050
|
+
const gutterX = (column, side) => {
|
|
2051
|
+
const box = columns[column]?.bounds ?? {
|
|
2052
|
+
x: 0,
|
|
2053
|
+
y: 0,
|
|
2054
|
+
width: 0,
|
|
2055
|
+
height: 0
|
|
2056
|
+
};
|
|
2057
|
+
return side === "right" ? box.x + box.width + opts.columnGap / 2 : box.x - opts.columnGap / 2;
|
|
2058
|
+
};
|
|
2059
|
+
const frontByReport = new Map(reports.map((front) => [front.report, front]));
|
|
2060
|
+
for (const [cardIndex, card] of cards.entries()) {
|
|
2061
|
+
const front = frontByReport.get(card.report);
|
|
2062
|
+
if (front === void 0) continue;
|
|
2063
|
+
const ownFrame = card.frame;
|
|
2064
|
+
const ownColumn = card.column;
|
|
2065
|
+
for (const [kind, ids] of [["to", front.to], ["cc", front.cc]]) for (const id of new Set(ids)) {
|
|
2066
|
+
const target = frameIndexById(id);
|
|
2067
|
+
if (target < 0 || target === ownFrame) continue;
|
|
2068
|
+
const targetColumn = frames[target]?.column ?? 0;
|
|
2069
|
+
const sameColumn = targetColumn === ownColumn;
|
|
2070
|
+
const side = sameColumn ? gutterSide(ownColumn) : targetColumn > ownColumn ? "right" : "left";
|
|
2071
|
+
pending.push({
|
|
2072
|
+
report: front.report,
|
|
2073
|
+
kind,
|
|
2074
|
+
from: cardEnd(cardIndex, side),
|
|
2075
|
+
to: frameEnd(target, sameColumn ? side : side === "right" ? "left" : "right"),
|
|
2076
|
+
external: externalBetween(ownFrame, target),
|
|
2077
|
+
...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
|
|
2078
|
+
});
|
|
2079
|
+
}
|
|
2080
|
+
for (const id of new Set(front.authors)) {
|
|
2081
|
+
if (id === front.from) continue;
|
|
2082
|
+
const source = frameIndexById(id);
|
|
2083
|
+
if (source < 0 || source === ownFrame) continue;
|
|
2084
|
+
const sourceColumn = frames[source]?.column ?? 0;
|
|
2085
|
+
const sameColumn = sourceColumn === ownColumn;
|
|
2086
|
+
const side = sameColumn ? gutterSide(ownColumn) : sourceColumn < ownColumn ? "right" : "left";
|
|
2087
|
+
pending.push({
|
|
2088
|
+
report: front.report,
|
|
2089
|
+
kind: "author",
|
|
2090
|
+
from: frameEnd(source, side),
|
|
2091
|
+
to: cardEnd(cardIndex, sameColumn ? side : side === "right" ? "left" : "right"),
|
|
2092
|
+
external: externalBetween(source, ownFrame),
|
|
2093
|
+
...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
|
|
2094
|
+
});
|
|
2095
|
+
}
|
|
2096
|
+
const parentIndex = front.parent === void 0 || front.parent === front.report ? void 0 : cardIndexOf.get(front.parent);
|
|
2097
|
+
const parentCard = parentIndex === void 0 ? void 0 : cards[parentIndex];
|
|
2098
|
+
if (parentIndex !== void 0 && parentCard !== void 0) {
|
|
2099
|
+
const from = cardEnd(parentIndex, "bottom");
|
|
2100
|
+
const to = cardEnd(cardIndex, "top");
|
|
2101
|
+
const column = parentCard.column;
|
|
2102
|
+
const target = card.column;
|
|
2103
|
+
const side = target > column ? "right" : target < column ? "left" : gutterSide(column);
|
|
2104
|
+
pending.push({
|
|
2105
|
+
report: front.report,
|
|
2106
|
+
kind: "thread",
|
|
2107
|
+
from,
|
|
2108
|
+
to,
|
|
2109
|
+
external: externalBetween(parentCard.frame, ownFrame),
|
|
2110
|
+
viaX: gutterX(column, side)
|
|
2111
|
+
});
|
|
2112
|
+
}
|
|
2113
|
+
}
|
|
2114
|
+
const groups = /* @__PURE__ */ new Map();
|
|
2115
|
+
for (const edge of pending) for (const end of [edge.from, edge.to]) {
|
|
2116
|
+
if (end.kind !== "frame") continue;
|
|
2117
|
+
const other = end === edge.from ? edge.to : edge.from;
|
|
2118
|
+
const key = `${end.index}|${end.side}`;
|
|
2119
|
+
const list = groups.get(key) ?? [];
|
|
2120
|
+
list.push({
|
|
2121
|
+
end,
|
|
2122
|
+
order: other.y,
|
|
2123
|
+
tie: `${edge.report}|${edge.kind}`
|
|
2124
|
+
});
|
|
2125
|
+
groups.set(key, list);
|
|
2126
|
+
}
|
|
2127
|
+
for (const list of groups.values()) {
|
|
2128
|
+
list.sort((a, b) => a.order - b.order || (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0));
|
|
2129
|
+
const box = fixed[list[0]?.end.index ?? 0] ?? {
|
|
2130
|
+
x: 0,
|
|
2131
|
+
y: 0,
|
|
2132
|
+
width: 0,
|
|
2133
|
+
height: 0
|
|
2134
|
+
};
|
|
2135
|
+
const top = box.y + opts.frameHeaderHeight + opts.framePaddingTop;
|
|
2136
|
+
const span = box.y + box.height - opts.framePaddingBottom - top;
|
|
2137
|
+
list.forEach((entry, index) => {
|
|
2138
|
+
entry.end.y = span > 0 ? top + (index + 1) * span / (list.length + 1) : box.y + box.height / 2;
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
const edges = pending.map((edge) => {
|
|
2142
|
+
const xs = [
|
|
2143
|
+
edge.from.x,
|
|
2144
|
+
edge.to.x,
|
|
2145
|
+
...edge.viaX === void 0 ? [] : [edge.viaX]
|
|
2146
|
+
];
|
|
2147
|
+
const ys = [edge.from.y, edge.to.y];
|
|
2148
|
+
const minX = Math.min(...xs);
|
|
2149
|
+
const minY = Math.min(...ys);
|
|
2150
|
+
return {
|
|
2151
|
+
report: edge.report,
|
|
2152
|
+
kind: edge.kind,
|
|
2153
|
+
from: { ...edge.from },
|
|
2154
|
+
to: { ...edge.to },
|
|
2155
|
+
external: edge.external,
|
|
2156
|
+
...edge.viaX === void 0 ? {} : { viaX: edge.viaX },
|
|
2157
|
+
bounds: {
|
|
2158
|
+
x: minX,
|
|
2159
|
+
y: minY,
|
|
2160
|
+
width: Math.max(...xs) - minX,
|
|
2161
|
+
height: Math.max(...ys) - minY
|
|
2162
|
+
}
|
|
2163
|
+
};
|
|
2164
|
+
});
|
|
2165
|
+
const contentWidth = columnCount === 0 ? 0 : opts.margin * 2 + columnCount * frameWidth + (columnCount - 1) * opts.columnGap;
|
|
2166
|
+
const widest = heights.reduce((max, height) => Math.max(max, height), 0);
|
|
2167
|
+
return {
|
|
2168
|
+
lod: opts.lod,
|
|
2169
|
+
columns,
|
|
2170
|
+
frames: layoutFrames,
|
|
2171
|
+
cards,
|
|
2172
|
+
edges,
|
|
2173
|
+
frameWidth,
|
|
2174
|
+
frameHeaderHeight: opts.frameHeaderHeight,
|
|
2175
|
+
cardWidth: size.width,
|
|
2176
|
+
cardHeight: size.height,
|
|
2177
|
+
width: columnCount === 0 ? 0 : contentWidth,
|
|
2178
|
+
height: columnCount === 0 ? 0 : opts.margin * 2 + widest
|
|
2179
|
+
};
|
|
2180
|
+
}
|
|
2181
|
+
/** How far a fitted drawing is kept from the box's left edge, in screen pixels. */
|
|
2182
|
+
const FIT_LEFT_MIN = 8;
|
|
2183
|
+
/** Where a fitted drawing starts, in screen pixels from the box's top. */
|
|
2184
|
+
const FIT_TOP = 12;
|
|
2185
|
+
/**
|
|
2186
|
+
* The transform that fills a box's width with the drawing, at the richest tier
|
|
2187
|
+
* that can afford it.
|
|
2188
|
+
*
|
|
2189
|
+
* Width is the target because time runs **down** here: a frame is a column, so
|
|
2190
|
+
* the horizontal axis is what has to fit on screen and the vertical axis is what
|
|
2191
|
+
* the reader pans along. Magnification stops at {@link MAX_FIT_ZOOM}, so a
|
|
2192
|
+
* drawing too narrow to fill the box is left at its own size instead of
|
|
2193
|
+
* stretched.
|
|
2194
|
+
*
|
|
2195
|
+
* The scale and the tier are mutually dependent — the tier is derived from the
|
|
2196
|
+
* zoom ({@link lodFor}) and the zoom is computed from a layout — so one pass over
|
|
2197
|
+
* the current layout can leave the view drawing one tier while its offset was
|
|
2198
|
+
* centred for another. Probing every tier makes the result a function of the box
|
|
2199
|
+
* alone: idempotent, and independent of whichever tier the reader arrived in.
|
|
2200
|
+
*
|
|
2201
|
+
* This lives beside the geometry rather than in the view because it *is*
|
|
2202
|
+
* geometry: pure, byte-deterministic for one input, and the only part of the
|
|
2203
|
+
* fitting that can be tested without a browser.
|
|
2204
|
+
* @param payload - the endpoint payload, for the session frames.
|
|
2205
|
+
* @param reports - the reports to plot.
|
|
2206
|
+
* @param boxWidth - the paint box's width in screen pixels.
|
|
2207
|
+
* @param options - geometry, tier and threshold overrides.
|
|
2208
|
+
* @returns the transform, or `undefined` when there is nothing to fit into or
|
|
2209
|
+
* nothing to fit (an empty drawing, or a box with no width yet).
|
|
2210
|
+
*/
|
|
2211
|
+
function fitTransform(payload, reports, boxWidth, options = {}) {
|
|
2212
|
+
const opts = {
|
|
2213
|
+
...DEFAULT_CARDGRAPH_OPTIONS,
|
|
2214
|
+
...options
|
|
2215
|
+
};
|
|
2216
|
+
if (!Number.isFinite(boxWidth) || boxWidth <= 0) return void 0;
|
|
2217
|
+
const available = Math.max(0, boxWidth - opts.margin);
|
|
2218
|
+
const bands = {
|
|
2219
|
+
card: {
|
|
2220
|
+
min: opts.cardMinZoom,
|
|
2221
|
+
max: MAX_FIT_ZOOM
|
|
2222
|
+
},
|
|
2223
|
+
compact: {
|
|
2224
|
+
min: opts.compactMinZoom,
|
|
2225
|
+
max: opts.cardMinZoom
|
|
2226
|
+
},
|
|
2227
|
+
chip: {
|
|
2228
|
+
min: MIN_ZOOM,
|
|
2229
|
+
max: opts.compactMinZoom
|
|
2230
|
+
}
|
|
2231
|
+
};
|
|
2232
|
+
const probes = CARDGRAPH_LODS.map((tier) => ({
|
|
2233
|
+
tier,
|
|
2234
|
+
drawing: buildCardgraph(payload, reports, {
|
|
2235
|
+
...options,
|
|
2236
|
+
lod: tier
|
|
2237
|
+
})
|
|
2238
|
+
})).filter((probe) => probe.drawing.width > 0);
|
|
2239
|
+
if (probes.length === 0) return void 0;
|
|
2240
|
+
const chosen = probes.find((probe) => available / probe.drawing.width >= bands[probe.tier].min) ?? probes[probes.length - 1];
|
|
2241
|
+
const band = bands[chosen.tier];
|
|
2242
|
+
let scale = Math.min(Math.max(available / chosen.drawing.width, band.min), band.max);
|
|
2243
|
+
if (lodFor(scale, opts) !== chosen.tier) scale *= .999999999;
|
|
2244
|
+
return {
|
|
2245
|
+
lod: chosen.tier,
|
|
2246
|
+
scale,
|
|
2247
|
+
offsetX: Math.max(FIT_LEFT_MIN, (boxWidth - chosen.drawing.width * scale) / 2),
|
|
2248
|
+
offsetY: FIT_TOP
|
|
2249
|
+
};
|
|
2250
|
+
}
|
|
2251
|
+
//#endregion
|
|
2252
|
+
//#region src/client/cardgraph-painter.ts
|
|
2253
|
+
/** A guaranteed-visible theme, for a context with no tokens at all. */
|
|
2254
|
+
const FALLBACK_THEME = {
|
|
2255
|
+
canvas: "#ffffff",
|
|
2256
|
+
frame: "#f6f7f9",
|
|
2257
|
+
frameBar: "#eef0f3",
|
|
2258
|
+
surface: "#ffffff",
|
|
2259
|
+
border: "#d8dbe0",
|
|
2260
|
+
frameBorder: "#c3c7ce",
|
|
2261
|
+
text: "#1a1c1f",
|
|
2262
|
+
secondary: "#4a4f57",
|
|
2263
|
+
caption: "#70757d",
|
|
2264
|
+
tertiary: "#9aa0a8",
|
|
2265
|
+
accent: "#2f6fed",
|
|
2266
|
+
warning: "#c77700",
|
|
2267
|
+
success: "#1f9254",
|
|
2268
|
+
error: "#d33a2c",
|
|
2269
|
+
business: "#2f6fed",
|
|
2270
|
+
fonts: {
|
|
2271
|
+
title: {
|
|
2272
|
+
size: 13,
|
|
2273
|
+
weight: 500,
|
|
2274
|
+
lineHeight: 18,
|
|
2275
|
+
family: "system-ui, sans-serif"
|
|
2276
|
+
},
|
|
2277
|
+
id: {
|
|
2278
|
+
size: 12,
|
|
2279
|
+
weight: 600,
|
|
2280
|
+
lineHeight: 16,
|
|
2281
|
+
family: "system-ui, sans-serif"
|
|
2282
|
+
},
|
|
2283
|
+
body: {
|
|
2284
|
+
size: 11,
|
|
2285
|
+
weight: 400,
|
|
2286
|
+
lineHeight: 15,
|
|
2287
|
+
family: "system-ui, sans-serif"
|
|
2288
|
+
},
|
|
2289
|
+
meta: {
|
|
2290
|
+
size: 11,
|
|
2291
|
+
weight: 500,
|
|
2292
|
+
lineHeight: 15,
|
|
2293
|
+
family: "system-ui, sans-serif"
|
|
2294
|
+
}
|
|
2295
|
+
}
|
|
2296
|
+
};
|
|
2297
|
+
/** Token name → the field it fills. Keeping it as data means one reader, not twenty. */
|
|
2298
|
+
const TOKENS = [
|
|
2299
|
+
[
|
|
2300
|
+
"canvas",
|
|
2301
|
+
"--dsw-alias-bg-base",
|
|
2302
|
+
FALLBACK_THEME.canvas
|
|
2303
|
+
],
|
|
2304
|
+
[
|
|
2305
|
+
"frame",
|
|
2306
|
+
"--dsw-alias-bg-layer-1",
|
|
2307
|
+
FALLBACK_THEME.frame
|
|
2308
|
+
],
|
|
2309
|
+
[
|
|
2310
|
+
"frameBar",
|
|
2311
|
+
"--dsw-alias-bg-layer-2",
|
|
2312
|
+
FALLBACK_THEME.frameBar
|
|
2313
|
+
],
|
|
2314
|
+
[
|
|
2315
|
+
"surface",
|
|
2316
|
+
"--dsw-alias-bg-layer-3",
|
|
2317
|
+
FALLBACK_THEME.surface
|
|
2318
|
+
],
|
|
2319
|
+
[
|
|
2320
|
+
"border",
|
|
2321
|
+
"--dsw-alias-border-l1",
|
|
2322
|
+
FALLBACK_THEME.border
|
|
2323
|
+
],
|
|
2324
|
+
[
|
|
2325
|
+
"frameBorder",
|
|
2326
|
+
"--dsw-alias-border-l2",
|
|
2327
|
+
FALLBACK_THEME.frameBorder
|
|
2328
|
+
],
|
|
2329
|
+
[
|
|
2330
|
+
"text",
|
|
2331
|
+
"--dsw-alias-label-primary",
|
|
2332
|
+
FALLBACK_THEME.text
|
|
2333
|
+
],
|
|
2334
|
+
[
|
|
2335
|
+
"secondary",
|
|
2336
|
+
"--dsw-alias-label-secondary",
|
|
2337
|
+
FALLBACK_THEME.secondary
|
|
2338
|
+
],
|
|
2339
|
+
[
|
|
2340
|
+
"caption",
|
|
2341
|
+
"--dsw-alias-label-caption",
|
|
2342
|
+
FALLBACK_THEME.caption
|
|
2343
|
+
],
|
|
2344
|
+
[
|
|
2345
|
+
"tertiary",
|
|
2346
|
+
"--dsw-alias-label-tertiary",
|
|
2347
|
+
FALLBACK_THEME.tertiary
|
|
2348
|
+
],
|
|
2349
|
+
[
|
|
2350
|
+
"accent",
|
|
2351
|
+
"--dsw-alias-brand-primary",
|
|
2352
|
+
FALLBACK_THEME.accent
|
|
2353
|
+
],
|
|
2354
|
+
[
|
|
2355
|
+
"warning",
|
|
2356
|
+
"--dsw-alias-state-warn-primary",
|
|
2357
|
+
FALLBACK_THEME.warning
|
|
2358
|
+
],
|
|
2359
|
+
[
|
|
2360
|
+
"success",
|
|
2361
|
+
"--dsw-alias-state-success-primary",
|
|
2362
|
+
FALLBACK_THEME.success
|
|
2363
|
+
],
|
|
2364
|
+
[
|
|
2365
|
+
"error",
|
|
2366
|
+
"--dsw-alias-state-error-primary",
|
|
2367
|
+
FALLBACK_THEME.error
|
|
2368
|
+
],
|
|
2369
|
+
[
|
|
2370
|
+
"business",
|
|
2371
|
+
"--dsw-alias-state-business-primary",
|
|
2372
|
+
FALLBACK_THEME.business
|
|
2373
|
+
]
|
|
2374
|
+
];
|
|
2375
|
+
/** Font token → the field it fills. Each token carries its own size, weight and family. */
|
|
2376
|
+
const FONT_TOKENS = [
|
|
2377
|
+
[
|
|
2378
|
+
"title",
|
|
2379
|
+
"--dsw-font-xs-13",
|
|
2380
|
+
FALLBACK_THEME.fonts.title
|
|
2381
|
+
],
|
|
2382
|
+
[
|
|
2383
|
+
"id",
|
|
2384
|
+
"--dsw-font-xxs-strong-12",
|
|
2385
|
+
FALLBACK_THEME.fonts.id
|
|
2386
|
+
],
|
|
2387
|
+
[
|
|
2388
|
+
"body",
|
|
2389
|
+
"--dsw-font-xxxs-11",
|
|
2390
|
+
FALLBACK_THEME.fonts.body
|
|
2391
|
+
],
|
|
2392
|
+
[
|
|
2393
|
+
"meta",
|
|
2394
|
+
"--dsw-font-xxxs-strong-11",
|
|
2395
|
+
FALLBACK_THEME.fonts.meta
|
|
2396
|
+
]
|
|
2397
|
+
];
|
|
2398
|
+
/** The one token a theme change always moves, so a repaint can detect it cheaply. */
|
|
2399
|
+
const CANARY = "--dsw-alias-bg-base";
|
|
2400
|
+
/** Assemble a canvas font string. */
|
|
2401
|
+
function fontString(font) {
|
|
2402
|
+
return `${font.weight} ${font.size}px ${font.family}`;
|
|
2403
|
+
}
|
|
2404
|
+
/** Read one font token, taking its size/weight/line-height/family parts. */
|
|
2405
|
+
function readFont(read, token, fallback) {
|
|
2406
|
+
const size = Number.parseFloat(read(`${token}-font-size`));
|
|
2407
|
+
const weight = Number.parseFloat(read(`${token}-font-weight`));
|
|
2408
|
+
const lineHeight = Number.parseFloat(read(`${token}-line-height`));
|
|
2409
|
+
const family = read(`${token}-font-family`);
|
|
2410
|
+
return {
|
|
2411
|
+
size: Number.isFinite(size) && size > 0 ? size : fallback.size,
|
|
2412
|
+
weight: Number.isFinite(weight) && weight > 0 ? weight : fallback.weight,
|
|
2413
|
+
lineHeight: Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : fallback.lineHeight,
|
|
2414
|
+
family: family === "" ? fallback.family : family
|
|
2415
|
+
};
|
|
2416
|
+
}
|
|
2417
|
+
/**
|
|
2418
|
+
* Read the drawing's colours and fonts out of the live theme.
|
|
2419
|
+
* @param read - a token reader, normally `getComputedStyle(root).getPropertyValue`.
|
|
2420
|
+
* @returns the theme; every field falls back so a missing token cannot blank the drawing.
|
|
2421
|
+
*/
|
|
2422
|
+
function readTheme(read) {
|
|
2423
|
+
const pick = (token, fallback) => {
|
|
2424
|
+
const value = read(token).trim();
|
|
2425
|
+
return value === "" ? fallback : value;
|
|
2426
|
+
};
|
|
2427
|
+
const colours = {};
|
|
2428
|
+
for (const [field, token, fallback] of TOKENS) colours[field] = pick(token, fallback);
|
|
2429
|
+
const fonts = {};
|
|
2430
|
+
for (const [field, token, fallback] of FONT_TOKENS) fonts[field] = readFont(read, token, fallback);
|
|
2431
|
+
return {
|
|
2432
|
+
...FALLBACK_THEME,
|
|
2433
|
+
...colours,
|
|
2434
|
+
fonts: {
|
|
2435
|
+
...FALLBACK_THEME.fonts,
|
|
2436
|
+
...fonts
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
}
|
|
2440
|
+
/** Whether the reader was handed a list of candidates rather than one element. */
|
|
2441
|
+
const isElementList = (value) => Array.isArray(value);
|
|
2442
|
+
/**
|
|
2443
|
+
* Watch the theme through one token.
|
|
2444
|
+
*
|
|
2445
|
+
* `getComputedStyle(...).getPropertyValue` forces a style read, so doing twenty of
|
|
2446
|
+
* them per repaint would be paying for the theme on every hover. The canary is the
|
|
2447
|
+
* page background: if it did not move, nothing else did, and the cached theme is
|
|
2448
|
+
* returned.
|
|
2449
|
+
*
|
|
2450
|
+
* **The palette lives on `body`, so the candidates are a chain.** DSH declares
|
|
2451
|
+
* every `--dsw-alias-*` colour and every `--dsw-font-<tier>-*` part on `body` —
|
|
2452
|
+
* `:root` carries only the family and the radii — and custom properties inherit
|
|
2453
|
+
* downwards, so a reader pointed at `document.documentElement` reads the empty
|
|
2454
|
+
* string for all of them and the whole drawing collapses onto the fallback theme
|
|
2455
|
+
* (a white canvas in a dark shell). Handing over both elements, `body` first,
|
|
2456
|
+
* keeps the drawing on the palette the shell actually painted it with, and still
|
|
2457
|
+
* works if a host ever moves the tokens up to `:root`. The first candidate whose
|
|
2458
|
+
* canary is non-empty is the source; the probe runs per read, not once, because a
|
|
2459
|
+
* host may mount `body` after this reader exists.
|
|
2460
|
+
* @param root - one element, or the candidates in order of preference.
|
|
2461
|
+
* @param win - the window to read styles from.
|
|
2462
|
+
* @returns the reader.
|
|
2463
|
+
*/
|
|
2464
|
+
function createThemeReader(root, win) {
|
|
2465
|
+
const roots = isElementList(root) ? root : [root];
|
|
2466
|
+
const sources = /* @__PURE__ */ new Map();
|
|
2467
|
+
const readerOf = (element) => {
|
|
2468
|
+
const known = sources.get(element);
|
|
2469
|
+
if (known !== void 0) return known;
|
|
2470
|
+
const style = win.getComputedStyle(element);
|
|
2471
|
+
const read = (name) => style.getPropertyValue(name);
|
|
2472
|
+
sources.set(element, read);
|
|
2473
|
+
return read;
|
|
2474
|
+
};
|
|
2475
|
+
let canary;
|
|
2476
|
+
let cached;
|
|
2477
|
+
return { read() {
|
|
2478
|
+
let current = "";
|
|
2479
|
+
let source;
|
|
2480
|
+
for (const element of roots) {
|
|
2481
|
+
const read = readerOf(element);
|
|
2482
|
+
const value = read(CANARY);
|
|
2483
|
+
if (value === "") continue;
|
|
2484
|
+
current = value;
|
|
2485
|
+
source = read;
|
|
2486
|
+
break;
|
|
2487
|
+
}
|
|
2488
|
+
if (cached !== void 0 && current === canary) return cached;
|
|
2489
|
+
canary = current;
|
|
2490
|
+
cached = readTheme(source ?? (() => ""));
|
|
2491
|
+
return cached;
|
|
2492
|
+
} };
|
|
2493
|
+
}
|
|
2494
|
+
/**
|
|
2495
|
+
* Shorten text until it fits, ending it with an ellipsis.
|
|
2496
|
+
* @param text - the text to fit.
|
|
2497
|
+
* @param maxWidth - the room available.
|
|
2498
|
+
* @param measure - width of a candidate string.
|
|
2499
|
+
* @returns text that fits, or `'…'` when not even one character does.
|
|
2500
|
+
*/
|
|
2501
|
+
function elide(text, maxWidth, measure) {
|
|
2502
|
+
if (measure(text) <= maxWidth) return text;
|
|
2503
|
+
for (let take = text.length - 1; take > 0; take--) {
|
|
2504
|
+
const candidate = `${text.slice(0, take).trimEnd()}…`;
|
|
2505
|
+
if (measure(candidate) <= maxWidth) return candidate;
|
|
2506
|
+
}
|
|
2507
|
+
return "…";
|
|
2508
|
+
}
|
|
2509
|
+
/**
|
|
2510
|
+
* Wrap text to a width, eliding whatever does not fit in the last line.
|
|
2511
|
+
*
|
|
2512
|
+
* Character-by-character, with one concession to Latin text: when a line has to
|
|
2513
|
+
* break and there is a space far enough back, break at the space instead of mid
|
|
2514
|
+
* word. Chinese has no spaces, so a word-based rule would leave a long subject on
|
|
2515
|
+
* one line and elide most of it; this rule wraps it, and still avoids breaking
|
|
2516
|
+
* "report-ledger" in half when it can.
|
|
2517
|
+
* @param text - the text to lay out.
|
|
2518
|
+
* @param maxWidth - the room available.
|
|
2519
|
+
* @param measure - width of a candidate string.
|
|
2520
|
+
* @param maxLines - how many lines to produce.
|
|
2521
|
+
* @returns the lines, possibly empty.
|
|
2522
|
+
*/
|
|
2523
|
+
function foldText(text, maxWidth, measure, maxLines = 1) {
|
|
2524
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
2525
|
+
if (clean === "" || maxWidth <= 0 || maxLines < 1) return [];
|
|
2526
|
+
const lines = [];
|
|
2527
|
+
let rest = clean;
|
|
2528
|
+
while (rest !== "" && lines.length < maxLines) {
|
|
2529
|
+
if (lines.length === maxLines - 1) {
|
|
2530
|
+
lines.push(elide(rest, maxWidth, measure));
|
|
2531
|
+
break;
|
|
2532
|
+
}
|
|
2533
|
+
if (measure(rest) <= maxWidth) {
|
|
2534
|
+
lines.push(rest);
|
|
2535
|
+
break;
|
|
2536
|
+
}
|
|
2537
|
+
let cut = 0;
|
|
2538
|
+
for (let take = 1; take <= rest.length; take++) {
|
|
2539
|
+
if (measure(rest.slice(0, take)) > maxWidth) break;
|
|
2540
|
+
cut = take;
|
|
2541
|
+
}
|
|
2542
|
+
if (cut === 0) {
|
|
2543
|
+
lines.push(elide(rest, maxWidth, measure));
|
|
2544
|
+
break;
|
|
2545
|
+
}
|
|
2546
|
+
const space = rest.lastIndexOf(" ", cut);
|
|
2547
|
+
if (space > cut / 3) cut = space;
|
|
2548
|
+
lines.push(rest.slice(0, cut).trimEnd());
|
|
2549
|
+
rest = rest.slice(cut).trimStart();
|
|
2550
|
+
}
|
|
2551
|
+
return lines;
|
|
2552
|
+
}
|
|
2553
|
+
/** Corner radius of a card. */
|
|
2554
|
+
const CARD_RADIUS = 5;
|
|
2555
|
+
/** Corner radius of a frame. */
|
|
2556
|
+
const FRAME_RADIUS = 7;
|
|
2557
|
+
/** Inset from a card's edge to its text. */
|
|
2558
|
+
const CARD_PADDING = 9;
|
|
2559
|
+
/** Width of the status stripe down a card's left edge. */
|
|
2560
|
+
const STRIPE = 3;
|
|
2561
|
+
/** Arrowhead length and half-width. */
|
|
2562
|
+
const ARROW_LENGTH = 7;
|
|
2563
|
+
const ARROW_HALF = 3.5;
|
|
2564
|
+
/**
|
|
2565
|
+
* How far the scrim pushes everything but the focused path back.
|
|
2566
|
+
*
|
|
2567
|
+
* **Measured, then corrected.** The first version reused the swimlane's dim value
|
|
2568
|
+
* (0.18 left showing) and a screenshot of the real tab showed why that was wrong
|
|
2569
|
+
* there and wrong here: at 0.18 the frames, their titles and every other card
|
|
2570
|
+
* become unreadable, so tracing one path costs the reader the context the drawing
|
|
2571
|
+
* exists to provide. The swimlane had already learned the same thing from the other
|
|
2572
|
+
* side — *the relations recede, the participants stay* — and a canvas scrim is
|
|
2573
|
+
* blunter than a per-shape dim, so it has to be gentler: 0.45 showing keeps every
|
|
2574
|
+
* frame and card legible while the focused path, drawn back on top at full
|
|
2575
|
+
* strength, still reads as the thing being pointed at.
|
|
2576
|
+
*/
|
|
2577
|
+
const SCRIM_ALPHA = .55;
|
|
2578
|
+
/** Subject lines a full card has room for. */
|
|
2579
|
+
const SUBJECT_LINES = 2;
|
|
2580
|
+
/** The colour a status is drawn in, matching the tab's tones. */
|
|
2581
|
+
function statusColour(status, theme) {
|
|
2582
|
+
if (status === "open") return theme.warning;
|
|
2583
|
+
if (status === "acked") return theme.success;
|
|
2584
|
+
return theme.tertiary;
|
|
2585
|
+
}
|
|
2586
|
+
/**
|
|
2587
|
+
* How each kind of wire is drawn: the token its colour comes from, its dash
|
|
2588
|
+
* pattern, and its weight.
|
|
2589
|
+
*
|
|
2590
|
+
* Exported because the wires are canvas and the legend is DOM — the only way the
|
|
2591
|
+
* two cannot drift is for the legend to read this table instead of restating it in
|
|
2592
|
+
* CSS. The colour is a **token name**, not a value, so it resolves through the
|
|
2593
|
+
* theme like everything else.
|
|
2594
|
+
*/
|
|
2595
|
+
const WIRE_TOKENS = {
|
|
2596
|
+
to: {
|
|
2597
|
+
colour: "--dsw-alias-label-secondary",
|
|
2598
|
+
dash: [],
|
|
2599
|
+
width: 1.4
|
|
2600
|
+
},
|
|
2601
|
+
cc: {
|
|
2602
|
+
colour: "--dsw-alias-label-caption",
|
|
2603
|
+
dash: [2, 3],
|
|
2604
|
+
width: 1
|
|
2605
|
+
},
|
|
2606
|
+
author: {
|
|
2607
|
+
colour: "--dsw-alias-label-caption",
|
|
2608
|
+
dash: [1, 3],
|
|
2609
|
+
width: 1
|
|
2610
|
+
},
|
|
2611
|
+
thread: {
|
|
2612
|
+
colour: "--dsw-alias-state-business-primary",
|
|
2613
|
+
dash: [],
|
|
2614
|
+
width: 1.4
|
|
2615
|
+
}
|
|
2616
|
+
};
|
|
2617
|
+
/** Reverse lookup: which theme field a token fills. */
|
|
2618
|
+
const FIELD_OF_TOKEN = new Map(TOKENS.map(([field, token]) => [token, field]));
|
|
2619
|
+
/** How one kind of wire is drawn, resolved against a theme. */
|
|
2620
|
+
function wireStyle(kind, theme) {
|
|
2621
|
+
const spec = WIRE_TOKENS[kind];
|
|
2622
|
+
const field = FIELD_OF_TOKEN.get(spec.colour);
|
|
2623
|
+
return {
|
|
2624
|
+
stroke: field === void 0 ? theme.secondary : theme[field],
|
|
2625
|
+
dash: spec.dash,
|
|
2626
|
+
width: spec.width
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
/**
|
|
2630
|
+
* The polyline a wire is drawn as.
|
|
2631
|
+
*
|
|
2632
|
+
* A wire with a gutter bends through it in three segments — out, along, in — which
|
|
2633
|
+
* is what keeps a same-column wire from crossing the frame it lands on and a
|
|
2634
|
+
* thread from crossing the cards between its ends.
|
|
2635
|
+
* @param edge - the wire.
|
|
2636
|
+
* @returns between two and four points, first to last.
|
|
2637
|
+
*/
|
|
2638
|
+
function wirePoints(edge) {
|
|
2639
|
+
const from = {
|
|
2640
|
+
x: edge.from.x,
|
|
2641
|
+
y: edge.from.y
|
|
2642
|
+
};
|
|
2643
|
+
const to = {
|
|
2644
|
+
x: edge.to.x,
|
|
2645
|
+
y: edge.to.y
|
|
2646
|
+
};
|
|
2647
|
+
if (edge.viaX === void 0) return [from, to];
|
|
2648
|
+
return [
|
|
2649
|
+
from,
|
|
2650
|
+
{
|
|
2651
|
+
x: edge.viaX,
|
|
2652
|
+
y: from.y
|
|
2653
|
+
},
|
|
2654
|
+
{
|
|
2655
|
+
x: edge.viaX,
|
|
2656
|
+
y: to.y
|
|
2657
|
+
},
|
|
2658
|
+
to
|
|
2659
|
+
];
|
|
2660
|
+
}
|
|
2661
|
+
/** Trace a rounded rectangle into the current path. */
|
|
2662
|
+
function pathRoundRect(ctx, rect, radius) {
|
|
2663
|
+
const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
|
|
2664
|
+
ctx.beginPath();
|
|
2665
|
+
ctx.moveTo(rect.x + r, rect.y);
|
|
2666
|
+
ctx.lineTo(rect.x + rect.width - r, rect.y);
|
|
2667
|
+
ctx.arcTo(rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + r, r);
|
|
2668
|
+
ctx.lineTo(rect.x + rect.width, rect.y + rect.height - r);
|
|
2669
|
+
ctx.arcTo(rect.x + rect.width, rect.y + rect.height, rect.x + rect.width - r, rect.y + rect.height, r);
|
|
2670
|
+
ctx.lineTo(rect.x + r, rect.y + rect.height);
|
|
2671
|
+
ctx.arcTo(rect.x, rect.y + rect.height, rect.x, rect.y + rect.height - r, r);
|
|
2672
|
+
ctx.lineTo(rect.x, rect.y + r);
|
|
2673
|
+
ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
|
|
2674
|
+
ctx.closePath();
|
|
2675
|
+
}
|
|
2676
|
+
/**
|
|
2677
|
+
* Build a painter that draws into a canvas.
|
|
2678
|
+
*
|
|
2679
|
+
* One transform carries pan and zoom; everything after that is drawn in the
|
|
2680
|
+
* layout's own coordinates. Coordinates are deliberately **not** snapped to
|
|
2681
|
+
* pixels: draw.io rounds after its transform because its canvas is 1:1 screen
|
|
2682
|
+
* space, while ours is scaled, so snapping would have to happen per point in
|
|
2683
|
+
* screen space. Antialiasing is the honest default until a measurement says the
|
|
2684
|
+
* blur costs readability.
|
|
2685
|
+
* @param canvas - the canvas to draw into.
|
|
2686
|
+
* @returns the painter.
|
|
2687
|
+
*/
|
|
2688
|
+
function createCanvasPainter(canvas) {
|
|
2689
|
+
const ctx = canvas.getContext("2d");
|
|
2690
|
+
if (ctx === null) throw new Error("card canvas: no 2d context");
|
|
2691
|
+
let width = 0;
|
|
2692
|
+
let height = 0;
|
|
2693
|
+
let dpr = 1;
|
|
2694
|
+
/** Measured text widths, keyed by font and text: measuring is a layout call. */
|
|
2695
|
+
const widths = /* @__PURE__ */ new Map();
|
|
2696
|
+
const measure = (text) => {
|
|
2697
|
+
const key = `${ctx.font}\u0000${text}`;
|
|
2698
|
+
const hit = widths.get(key);
|
|
2699
|
+
if (hit !== void 0) return hit;
|
|
2700
|
+
const value = ctx.measureText(text).width;
|
|
2701
|
+
widths.set(key, value);
|
|
2702
|
+
return value;
|
|
2703
|
+
};
|
|
2704
|
+
const line = (x1, y1, x2, y2, colour, dash, alpha) => {
|
|
2705
|
+
ctx.globalAlpha = alpha;
|
|
2706
|
+
ctx.strokeStyle = colour;
|
|
2707
|
+
ctx.setLineDash(dash);
|
|
2708
|
+
ctx.beginPath();
|
|
2709
|
+
ctx.moveTo(x1, y1);
|
|
2710
|
+
ctx.lineTo(x2, y2);
|
|
2711
|
+
ctx.stroke();
|
|
2712
|
+
};
|
|
2713
|
+
/** The frame's outline alone, for the interaction layer: the box is already drawn. */
|
|
2714
|
+
const drawFrameOutline = (frame, scene) => {
|
|
2715
|
+
ctx.globalAlpha = 1;
|
|
2716
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
2717
|
+
ctx.strokeStyle = scene.theme.accent;
|
|
2718
|
+
ctx.lineWidth = 1.6;
|
|
2719
|
+
ctx.stroke();
|
|
2720
|
+
};
|
|
2721
|
+
const drawFrame = (frame, scene) => {
|
|
2722
|
+
const { theme } = scene;
|
|
2723
|
+
ctx.globalAlpha = 1;
|
|
2724
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
2725
|
+
ctx.fillStyle = theme.frame;
|
|
2726
|
+
ctx.fill();
|
|
2727
|
+
ctx.strokeStyle = theme.frameBorder;
|
|
2728
|
+
ctx.lineWidth = 1;
|
|
2729
|
+
ctx.stroke();
|
|
2730
|
+
const bar = {
|
|
2731
|
+
x: frame.bounds.x,
|
|
2732
|
+
y: frame.bounds.y,
|
|
2733
|
+
width: frame.bounds.width,
|
|
2734
|
+
height: scene.layout.frameHeaderHeight
|
|
2735
|
+
};
|
|
2736
|
+
ctx.save();
|
|
2737
|
+
pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
|
|
2738
|
+
ctx.clip();
|
|
2739
|
+
ctx.globalAlpha = 1;
|
|
2740
|
+
ctx.fillStyle = theme.frameBar;
|
|
2741
|
+
ctx.fillRect(bar.x, bar.y, bar.width, bar.height);
|
|
2742
|
+
ctx.restore();
|
|
2743
|
+
line(bar.x, bar.y + bar.height, bar.x + bar.width, bar.y + bar.height, theme.frameBorder, [], 1);
|
|
2744
|
+
ctx.font = fontString(theme.fonts.title);
|
|
2745
|
+
ctx.textAlign = "left";
|
|
2746
|
+
ctx.textBaseline = "middle";
|
|
2747
|
+
ctx.fillStyle = theme.secondary;
|
|
2748
|
+
const label = frame.external ? scene.labels.external : frame.title ?? frame.shortId;
|
|
2749
|
+
ctx.fillText(elide(label, frame.bounds.width - 24 - 10, measure), bar.x + 10, bar.y + bar.height / 2 + .5);
|
|
2750
|
+
const cx = bar.x + bar.width - 13;
|
|
2751
|
+
const cy = bar.y + bar.height / 2;
|
|
2752
|
+
ctx.beginPath();
|
|
2753
|
+
ctx.arc(cx, cy, 3.5, 0, Math.PI * 2);
|
|
2754
|
+
if (frame.live) {
|
|
2755
|
+
ctx.fillStyle = theme.success;
|
|
2756
|
+
ctx.fill();
|
|
2757
|
+
} else {
|
|
2758
|
+
ctx.strokeStyle = theme.tertiary;
|
|
2759
|
+
ctx.lineWidth = 1;
|
|
2760
|
+
ctx.stroke();
|
|
2761
|
+
}
|
|
2762
|
+
if (frame.cards === 0) {
|
|
2763
|
+
ctx.font = fontString(theme.fonts.body);
|
|
2764
|
+
ctx.fillStyle = theme.tertiary;
|
|
2765
|
+
ctx.fillText(scene.labels.empty, frame.bounds.x + CARD_PADDING, bar.y + bar.height + 18);
|
|
2766
|
+
}
|
|
2767
|
+
};
|
|
2768
|
+
const drawWire = (edge, scene, alpha) => {
|
|
2769
|
+
const { theme } = scene;
|
|
2770
|
+
const style = wireStyle(edge.kind, theme);
|
|
2771
|
+
const points = wirePoints(edge);
|
|
2772
|
+
ctx.globalAlpha = alpha;
|
|
2773
|
+
ctx.strokeStyle = style.stroke;
|
|
2774
|
+
ctx.lineWidth = style.width;
|
|
2775
|
+
ctx.setLineDash(style.dash);
|
|
2776
|
+
ctx.beginPath();
|
|
2777
|
+
points.forEach((point, index) => {
|
|
2778
|
+
if (index === 0) ctx.moveTo(point.x, point.y);
|
|
2779
|
+
else ctx.lineTo(point.x, point.y);
|
|
2780
|
+
});
|
|
2781
|
+
ctx.stroke();
|
|
2782
|
+
const last = points[points.length - 2];
|
|
2783
|
+
const end = points[points.length - 1];
|
|
2784
|
+
if (last === void 0 || end === void 0) return;
|
|
2785
|
+
const angle = Math.atan2(end.y - last.y, end.x - last.x);
|
|
2786
|
+
const back = {
|
|
2787
|
+
x: end.x - Math.cos(angle) * ARROW_LENGTH,
|
|
2788
|
+
y: end.y - Math.sin(angle) * ARROW_LENGTH
|
|
2789
|
+
};
|
|
2790
|
+
const side = {
|
|
2791
|
+
x: -Math.sin(angle) * ARROW_HALF,
|
|
2792
|
+
y: Math.cos(angle) * ARROW_HALF
|
|
2793
|
+
};
|
|
2794
|
+
ctx.setLineDash([]);
|
|
2795
|
+
ctx.fillStyle = style.stroke;
|
|
2796
|
+
ctx.beginPath();
|
|
2797
|
+
ctx.moveTo(end.x, end.y);
|
|
2798
|
+
ctx.lineTo(back.x + side.x, back.y + side.y);
|
|
2799
|
+
ctx.lineTo(back.x - side.x, back.y - side.y);
|
|
2800
|
+
ctx.closePath();
|
|
2801
|
+
ctx.fill();
|
|
2802
|
+
};
|
|
2803
|
+
const drawCard = (card, scene, accent) => {
|
|
2804
|
+
const { theme } = scene;
|
|
2805
|
+
const box = card.bounds;
|
|
2806
|
+
ctx.globalAlpha = 1;
|
|
2807
|
+
pathRoundRect(ctx, box, CARD_RADIUS);
|
|
2808
|
+
ctx.fillStyle = card.lod === "chip" ? statusColour(card.status, theme) : theme.surface;
|
|
2809
|
+
ctx.fill();
|
|
2810
|
+
if (card.lod === "chip") return;
|
|
2811
|
+
ctx.strokeStyle = accent ? theme.accent : theme.border;
|
|
2812
|
+
ctx.lineWidth = accent ? 1.6 : 1;
|
|
2813
|
+
ctx.stroke();
|
|
2814
|
+
ctx.save();
|
|
2815
|
+
pathRoundRect(ctx, box, CARD_RADIUS);
|
|
2816
|
+
ctx.clip();
|
|
2817
|
+
ctx.fillStyle = statusColour(card.status, theme);
|
|
2818
|
+
ctx.fillRect(box.x, box.y, STRIPE, box.height);
|
|
2819
|
+
ctx.restore();
|
|
2820
|
+
const textLeft = box.x + CARD_PADDING + STRIPE;
|
|
2821
|
+
const textWidth = box.width - CARD_PADDING * 2 - STRIPE;
|
|
2822
|
+
ctx.textAlign = "left";
|
|
2823
|
+
ctx.textBaseline = "top";
|
|
2824
|
+
ctx.font = fontString(theme.fonts.id);
|
|
2825
|
+
ctx.fillStyle = accent ? theme.accent : theme.secondary;
|
|
2826
|
+
if (card.lod === "compact") {
|
|
2827
|
+
const id = card.report;
|
|
2828
|
+
const idWidth = measure(id);
|
|
2829
|
+
ctx.fillText(id, textLeft, box.y + box.height / 2 - theme.fonts.id.size / 2 - 1);
|
|
2830
|
+
ctx.font = fontString(theme.fonts.body);
|
|
2831
|
+
ctx.fillStyle = theme.caption;
|
|
2832
|
+
const rest = foldText(`· ${card.subject}`, textWidth - idWidth - 4, measure, 1)[0] ?? "";
|
|
2833
|
+
ctx.fillText(rest, textLeft + idWidth + 4, box.y + box.height / 2 - theme.fonts.body.size / 2 - 1);
|
|
2834
|
+
return;
|
|
2835
|
+
}
|
|
2836
|
+
ctx.fillText(card.report, textLeft, box.y + 8);
|
|
2837
|
+
ctx.font = fontString(theme.fonts.body);
|
|
2838
|
+
ctx.fillStyle = theme.text;
|
|
2839
|
+
foldText(card.subject, textWidth, measure, SUBJECT_LINES).forEach((text, index) => {
|
|
2840
|
+
ctx.fillText(text, textLeft, box.y + 28 + index * theme.fonts.body.lineHeight);
|
|
2841
|
+
});
|
|
2842
|
+
ctx.font = fontString(theme.fonts.meta);
|
|
2843
|
+
const baseline = box.y + box.height - theme.fonts.meta.lineHeight - 6;
|
|
2844
|
+
ctx.fillStyle = statusColour(card.status, theme);
|
|
2845
|
+
const status = scene.labels.status[card.status] ?? card.status;
|
|
2846
|
+
ctx.fillText(status, textLeft, baseline);
|
|
2847
|
+
if (card.task !== void 0) {
|
|
2848
|
+
const chipLeft = textLeft + measure(status) + 8;
|
|
2849
|
+
const chipText = foldText(card.task, box.x + box.width - CARD_PADDING - chipLeft - 10, measure, 1)[0] ?? "";
|
|
2850
|
+
if (chipText !== "") {
|
|
2851
|
+
ctx.fillStyle = theme.frameBar;
|
|
2852
|
+
const chipWidth = measure(chipText) + 10;
|
|
2853
|
+
ctx.fillRect(chipLeft, baseline - 2, chipWidth, theme.fonts.meta.lineHeight + 2);
|
|
2854
|
+
ctx.fillStyle = theme.caption;
|
|
2855
|
+
ctx.fillText(chipText, chipLeft + 5, baseline);
|
|
2856
|
+
}
|
|
2857
|
+
}
|
|
2858
|
+
};
|
|
2859
|
+
return {
|
|
2860
|
+
resize(w, h, ratio) {
|
|
2861
|
+
width = Math.max(0, w);
|
|
2862
|
+
height = Math.max(0, h);
|
|
2863
|
+
dpr = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
|
|
2864
|
+
canvas.width = Math.max(1, Math.round(width * dpr));
|
|
2865
|
+
canvas.height = Math.max(1, Math.round(height * dpr));
|
|
2866
|
+
canvas.style.width = `${width}px`;
|
|
2867
|
+
canvas.style.height = `${height}px`;
|
|
2868
|
+
},
|
|
2869
|
+
reset() {
|
|
2870
|
+
widths.clear();
|
|
2871
|
+
},
|
|
2872
|
+
paint(scene) {
|
|
2873
|
+
const { layout, theme, viewport } = scene;
|
|
2874
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2875
|
+
ctx.globalAlpha = 1;
|
|
2876
|
+
ctx.setLineDash([]);
|
|
2877
|
+
ctx.clearRect(0, 0, width, height);
|
|
2878
|
+
ctx.fillStyle = theme.canvas;
|
|
2879
|
+
ctx.fillRect(0, 0, width, height);
|
|
2880
|
+
const world = worldViewport(viewport, scene.overscan ?? 0);
|
|
2881
|
+
const visible = (rect) => intersects(rect, world);
|
|
2882
|
+
ctx.save();
|
|
2883
|
+
ctx.translate(viewport.offsetX, viewport.offsetY);
|
|
2884
|
+
ctx.scale(viewport.scale, viewport.scale);
|
|
2885
|
+
for (const frame of layout.frames) if (visible(frame.bounds)) drawFrame(frame, scene);
|
|
2886
|
+
for (const edge of layout.edges) if (visible(edge.bounds)) drawWire(edge, scene, 1);
|
|
2887
|
+
for (const card of layout.cards) {
|
|
2888
|
+
if (!visible(card.bounds)) continue;
|
|
2889
|
+
drawCard(card, scene, card.report === scene.focus?.selected);
|
|
2890
|
+
}
|
|
2891
|
+
ctx.globalAlpha = 1;
|
|
2892
|
+
ctx.restore();
|
|
2893
|
+
},
|
|
2894
|
+
paintPath(scene) {
|
|
2895
|
+
const { layout, theme, viewport } = scene;
|
|
2896
|
+
const report = scene.focus?.report ?? scene.focus?.selected;
|
|
2897
|
+
const frameIndex = scene.focus?.frame;
|
|
2898
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
2899
|
+
ctx.globalAlpha = 1;
|
|
2900
|
+
ctx.setLineDash([]);
|
|
2901
|
+
ctx.clearRect(0, 0, width, height);
|
|
2902
|
+
if (report === void 0 && frameIndex === void 0) return;
|
|
2903
|
+
ctx.save();
|
|
2904
|
+
ctx.translate(viewport.offsetX, viewport.offsetY);
|
|
2905
|
+
ctx.scale(viewport.scale, viewport.scale);
|
|
2906
|
+
if (report === void 0) {
|
|
2907
|
+
const frame = layout.frames[frameIndex];
|
|
2908
|
+
if (frame !== void 0) drawFrameOutline(frame, scene);
|
|
2909
|
+
ctx.restore();
|
|
2910
|
+
return;
|
|
2911
|
+
}
|
|
2912
|
+
const world = worldViewport(viewport, 0);
|
|
2913
|
+
ctx.globalAlpha = SCRIM_ALPHA;
|
|
2914
|
+
ctx.fillStyle = theme.canvas;
|
|
2915
|
+
ctx.fillRect(world.x, world.y, world.width, world.height);
|
|
2916
|
+
ctx.globalAlpha = 1;
|
|
2917
|
+
const edges = layout.edges.filter((edge) => edge.report === report);
|
|
2918
|
+
const touched = /* @__PURE__ */ new Set();
|
|
2919
|
+
for (const edge of edges) {
|
|
2920
|
+
if (edge.from.kind === "frame") touched.add(edge.from.index);
|
|
2921
|
+
if (edge.to.kind === "frame") touched.add(edge.to.index);
|
|
2922
|
+
}
|
|
2923
|
+
for (const frame of layout.frames) if (touched.has(frame.index)) drawFrameOutline(frame, scene);
|
|
2924
|
+
for (const edge of edges) if (intersects(edge.bounds, world)) drawWire(edge, scene, 1);
|
|
2925
|
+
for (const card of layout.cards) if (card.report === report) drawCard(card, scene, true);
|
|
2926
|
+
ctx.restore();
|
|
2927
|
+
}
|
|
2928
|
+
};
|
|
2929
|
+
}
|
|
2930
|
+
//#endregion
|
|
2931
|
+
//#region src/client/CardgraphView.tsx
|
|
2932
|
+
/**
|
|
2933
|
+
* The card canvas's view: two canvases, a pan/zoom transform, picking, and an
|
|
2934
|
+
* invisible button layer.
|
|
2935
|
+
*
|
|
2936
|
+
* The drawing is split across two surfaces, and that split is the whole
|
|
2937
|
+
* performance story:
|
|
2938
|
+
*
|
|
2939
|
+
* - the **base** canvas holds frames, wires and cards, and is repainted only when
|
|
2940
|
+
* the drawing itself changes (layout, transform, size, theme);
|
|
2941
|
+
* - the **interaction** canvas holds one report's path over a scrim, and is
|
|
2942
|
+
* repainted on every hover. Its cost does not grow with the ledger, because it
|
|
2943
|
+
* never touches the shapes underneath.
|
|
2944
|
+
*
|
|
2945
|
+
* Picking is arithmetic rather than elements: the canvas has nothing to hit, so
|
|
2946
|
+
* `hitTest` from the model is given the cards (topmost first) and then the frames.
|
|
2947
|
+
* What the canvas also cannot offer is accessibility — so every card that is on
|
|
2948
|
+
* screen gets a real, transparent `<button>` on top of it, which is what a keyboard
|
|
2949
|
+
* or a screen reader reaches, and the list below stays the full accessible path.
|
|
2950
|
+
*
|
|
2951
|
+
* @module dsh-report-ledger/client/CardgraphView
|
|
2952
|
+
*/
|
|
2953
|
+
/**
|
|
2954
|
+
* Wheel-zoom exponent per pixel of delta, so that one 100px notch — a mouse
|
|
2955
|
+
* wheel's usual magnitude — is exactly the {@link ZOOM_STEP} the buttons take.
|
|
2956
|
+
* A trackpad pinch arrives as the same event with much smaller deltas, which is
|
|
2957
|
+
* why the zoom is exponential in the delta rather than one step per event.
|
|
2958
|
+
*/
|
|
2959
|
+
const WHEEL_ZOOM_RATE = Math.log(ZOOM_STEP) / 100;
|
|
2960
|
+
/** How close to the box's edge a wheel-pan stops, in screen pixels. */
|
|
2961
|
+
const EDGE_MARGIN = 12;
|
|
2962
|
+
/** Drawing units to keep beyond the viewport, so a pan does not reveal a blank band. */
|
|
2963
|
+
const OVERSCAN = 160;
|
|
2964
|
+
/** How tall the canvas box is, inside the tab. */
|
|
2965
|
+
const VIEW_HEIGHT = 420;
|
|
2966
|
+
/** How far the pointer may travel before a press becomes a drag instead of a click. */
|
|
2967
|
+
const DRAG_SLOP = 4;
|
|
2968
|
+
/** The four kinds, in the order the legend lists them. */
|
|
2969
|
+
const KINDS = [
|
|
2970
|
+
"to",
|
|
2971
|
+
"cc",
|
|
2972
|
+
"author",
|
|
2973
|
+
"thread"
|
|
2974
|
+
];
|
|
2975
|
+
/**
|
|
2976
|
+
* Whether the pointer is on the same thing.
|
|
2977
|
+
*
|
|
2978
|
+
* A pointermove over one card is not a new hover: without this the state object is
|
|
2979
|
+
* replaced on every move, which re-renders the drawing and repaints the overlay for
|
|
2980
|
+
* no change at all.
|
|
2981
|
+
*/
|
|
2982
|
+
const sameHover = (a, b) => a.report === b.report && a.frame === b.frame;
|
|
2983
|
+
/**
|
|
2984
|
+
* The legend.
|
|
2985
|
+
*
|
|
2986
|
+
* Built from the painter's own wire table (`WIRE_TOKENS`) rather than from CSS, so
|
|
2987
|
+
* a legend that disagreed with the lines beside it would take a change to that one
|
|
2988
|
+
* table — the drift the swimlane's legend avoided a different way (by sharing a
|
|
2989
|
+
* class name with its paths).
|
|
2990
|
+
* @param props - the translator.
|
|
2991
|
+
* @returns the legend row.
|
|
2992
|
+
*/
|
|
2993
|
+
function CardgraphLegend({ t }) {
|
|
2994
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
|
|
2995
|
+
style: {
|
|
2996
|
+
display: "flex",
|
|
2997
|
+
alignItems: "center",
|
|
2998
|
+
gap: "12px",
|
|
2999
|
+
flexWrap: "wrap"
|
|
3000
|
+
},
|
|
3001
|
+
children: KINDS.map((kind) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
|
|
3002
|
+
style: {
|
|
3003
|
+
display: "inline-flex",
|
|
3004
|
+
alignItems: "center",
|
|
3005
|
+
gap: "6px",
|
|
3006
|
+
fontSize: "var(--dsw-font-xxxs-11, 11px)",
|
|
3007
|
+
color: "var(--dsw-alias-label-caption)"
|
|
3008
|
+
},
|
|
3009
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
|
|
3010
|
+
width: "26",
|
|
3011
|
+
height: "8",
|
|
3012
|
+
"aria-hidden": "true",
|
|
3013
|
+
focusable: "false",
|
|
3014
|
+
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
|
|
3015
|
+
x1: "0",
|
|
3016
|
+
y1: "4",
|
|
3017
|
+
x2: "26",
|
|
3018
|
+
y2: "4",
|
|
3019
|
+
stroke: `var(${WIRE_TOKENS[kind].colour})`,
|
|
3020
|
+
strokeWidth: WIRE_TOKENS[kind].width,
|
|
3021
|
+
...WIRE_TOKENS[kind].dash.length === 0 ? {} : { strokeDasharray: WIRE_TOKENS[kind].dash.join(" ") }
|
|
3022
|
+
})
|
|
3023
|
+
}), t(EDGE_LABEL[kind])]
|
|
3024
|
+
}, kind))
|
|
3025
|
+
});
|
|
3026
|
+
}
|
|
3027
|
+
/** Keep a number inside a range. */
|
|
3028
|
+
const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
|
|
3029
|
+
/**
|
|
3030
|
+
* The drawing.
|
|
3031
|
+
* @param props - payload, the reports to plot, translations, and the open handler.
|
|
3032
|
+
* @returns the canvas box with its controls.
|
|
3033
|
+
*/
|
|
3034
|
+
function CardgraphView({ payload, reports, t, openReport, onOpen, idHint, fill = false }) {
|
|
3035
|
+
const boxRef = (0, react.useRef)(null);
|
|
3036
|
+
const baseRef = (0, react.useRef)(null);
|
|
3037
|
+
const pathRef = (0, react.useRef)(null);
|
|
3038
|
+
const painter = (0, react.useRef)(null);
|
|
3039
|
+
const pathPainter = (0, react.useRef)(null);
|
|
3040
|
+
const reader = (0, react.useRef)(null);
|
|
3041
|
+
const drag = (0, react.useRef)(null);
|
|
3042
|
+
const [size, setSize] = (0, react.useState)({
|
|
3043
|
+
width: 0,
|
|
3044
|
+
height: VIEW_HEIGHT
|
|
3045
|
+
});
|
|
3046
|
+
const [zoom, setZoom] = (0, react.useState)(1);
|
|
3047
|
+
const [offset, setOffset] = (0, react.useState)({
|
|
3048
|
+
x: 0,
|
|
3049
|
+
y: 0
|
|
3050
|
+
});
|
|
3051
|
+
const [hover, setHover] = (0, react.useState)({});
|
|
3052
|
+
const [ready, setReady] = (0, react.useState)(false);
|
|
3053
|
+
/** Bumped when the palette changed, so the theme is read again. */
|
|
3054
|
+
const [themeTick, setThemeTick] = (0, react.useState)(0);
|
|
3055
|
+
/** Bumped when font metrics may have arrived, so the measured text is dropped. */
|
|
3056
|
+
const [fontTick, setFontTick] = (0, react.useState)(0);
|
|
3057
|
+
/** Bumped when the device pixel ratio may have changed. */
|
|
3058
|
+
const [dprTick, setDprTick] = (0, react.useState)(0);
|
|
3059
|
+
const fitted = (0, react.useRef)(false);
|
|
3060
|
+
/**
|
|
3061
|
+
* Whether the transform on screen is still the fit's own.
|
|
3062
|
+
*
|
|
3063
|
+
* While this holds, the fit follows the box; the moment the reader zooms or pans
|
|
3064
|
+
* the view themselves it stops following, because from then on the transform is
|
|
3065
|
+
* a decision rather than a consequence — see the resize effect below.
|
|
3066
|
+
*/
|
|
3067
|
+
const fittedTransform = (0, react.useRef)(true);
|
|
3068
|
+
/** The last box the resize effect saw, so a re-measure is not a resize. */
|
|
3069
|
+
const measured = (0, react.useRef)({
|
|
3070
|
+
width: 0,
|
|
3071
|
+
height: 0
|
|
3072
|
+
});
|
|
3073
|
+
/** The transform as of this render, for the one listener that is attached once. */
|
|
3074
|
+
const zoomRef = (0, react.useRef)(zoom);
|
|
3075
|
+
const offsetRef = (0, react.useRef)(offset);
|
|
3076
|
+
zoomRef.current = zoom;
|
|
3077
|
+
offsetRef.current = offset;
|
|
3078
|
+
const lod = lodFor(zoom);
|
|
3079
|
+
const layout = (0, react.useMemo)(() => buildCardgraph(payload, reports, { lod }), [
|
|
3080
|
+
payload,
|
|
3081
|
+
reports,
|
|
3082
|
+
lod
|
|
3083
|
+
]);
|
|
3084
|
+
/** The drawing as of this render, for that same listener: it needs its extent. */
|
|
3085
|
+
const layoutRef = (0, react.useRef)(layout);
|
|
3086
|
+
layoutRef.current = layout;
|
|
3087
|
+
const labels = (0, react.useMemo)(() => {
|
|
3088
|
+
const status = {};
|
|
3089
|
+
for (const [state, key] of Object.entries(STATUS_LABEL)) status[state] = t(key);
|
|
3090
|
+
return {
|
|
3091
|
+
external: t("cardgraph.external"),
|
|
3092
|
+
empty: t("cardgraph.empty"),
|
|
3093
|
+
status
|
|
3094
|
+
};
|
|
3095
|
+
}, [t]);
|
|
3096
|
+
(0, react.useEffect)(() => {
|
|
3097
|
+
const base = baseRef.current;
|
|
3098
|
+
const path = pathRef.current;
|
|
3099
|
+
if (base === null || path === null) return;
|
|
3100
|
+
painter.current = createCanvasPainter(base);
|
|
3101
|
+
pathPainter.current = createCanvasPainter(path);
|
|
3102
|
+
const doc = globalThis.document;
|
|
3103
|
+
reader.current = createThemeReader([doc.body, doc.documentElement], globalThis.window);
|
|
3104
|
+
const palette = new MutationObserver(() => {
|
|
3105
|
+
setThemeTick((tick) => tick + 1);
|
|
3106
|
+
});
|
|
3107
|
+
palette.observe(doc.body, {
|
|
3108
|
+
attributes: true,
|
|
3109
|
+
attributeFilter: ["data-ds-dark-theme"]
|
|
3110
|
+
});
|
|
3111
|
+
doc.fonts.ready.then(() => {
|
|
3112
|
+
painter.current?.reset();
|
|
3113
|
+
pathPainter.current?.reset();
|
|
3114
|
+
setFontTick((tick) => tick + 1);
|
|
3115
|
+
});
|
|
3116
|
+
setReady(true);
|
|
3117
|
+
return () => {
|
|
3118
|
+
palette.disconnect();
|
|
3119
|
+
painter.current = null;
|
|
3120
|
+
pathPainter.current = null;
|
|
3121
|
+
reader.current = null;
|
|
3122
|
+
setReady(false);
|
|
3123
|
+
};
|
|
3124
|
+
}, []);
|
|
3125
|
+
(0, react.useEffect)(() => {
|
|
3126
|
+
const box = boxRef.current;
|
|
3127
|
+
if (box === null) return;
|
|
3128
|
+
const measure = () => {
|
|
3129
|
+
setSize({
|
|
3130
|
+
width: box.clientWidth,
|
|
3131
|
+
height: box.clientHeight
|
|
3132
|
+
});
|
|
3133
|
+
};
|
|
3134
|
+
measure();
|
|
3135
|
+
const observer = new ResizeObserver(measure);
|
|
3136
|
+
observer.observe(box);
|
|
3137
|
+
return () => {
|
|
3138
|
+
observer.disconnect();
|
|
3139
|
+
};
|
|
3140
|
+
}, []);
|
|
3141
|
+
(0, react.useEffect)(() => {
|
|
3142
|
+
const win = globalThis.window;
|
|
3143
|
+
const bump = () => {
|
|
3144
|
+
setDprTick((tick) => tick + 1);
|
|
3145
|
+
};
|
|
3146
|
+
win.addEventListener("resize", bump);
|
|
3147
|
+
return () => {
|
|
3148
|
+
win.removeEventListener("resize", bump);
|
|
3149
|
+
};
|
|
3150
|
+
}, []);
|
|
3151
|
+
const viewport = (0, react.useMemo)(() => ({
|
|
3152
|
+
offsetX: offset.x,
|
|
3153
|
+
offsetY: offset.y,
|
|
3154
|
+
scale: zoom,
|
|
3155
|
+
width: size.width,
|
|
3156
|
+
height: size.height
|
|
3157
|
+
}), [
|
|
3158
|
+
offset,
|
|
3159
|
+
zoom,
|
|
3160
|
+
size
|
|
3161
|
+
]);
|
|
3162
|
+
/**
|
|
3163
|
+
* The theme the drawing is painted in.
|
|
3164
|
+
*
|
|
3165
|
+
* Read through the reader (which caches until the canary token moves) and keyed by
|
|
3166
|
+
* `ready` so the first real read happens once the reader exists, and by
|
|
3167
|
+
* `themeTick` so a palette switch repaints without waiting for the next poll.
|
|
3168
|
+
*/
|
|
3169
|
+
const theme = (0, react.useMemo)(() => ready ? reader.current?.read() ?? FALLBACK_THEME : FALLBACK_THEME, [ready, themeTick]);
|
|
3170
|
+
/**
|
|
3171
|
+
* The base layer's scene.
|
|
3172
|
+
*
|
|
3173
|
+
* Carries the open report — the selected card is accented there — but deliberately
|
|
3174
|
+
* **not** the hover: the bottom layer is repainted only when the drawing itself
|
|
3175
|
+
* changes, and a pointer moving over the canvas is not a change to the drawing.
|
|
3176
|
+
* The hover travels in {@link overlayScene}, which is what the second canvas is for.
|
|
3177
|
+
*/
|
|
3178
|
+
const baseScene = (0, react.useMemo)(() => ({
|
|
3179
|
+
layout,
|
|
3180
|
+
theme,
|
|
3181
|
+
viewport,
|
|
3182
|
+
labels,
|
|
3183
|
+
overscan: OVERSCAN,
|
|
3184
|
+
focus: openReport === void 0 ? {} : { selected: openReport }
|
|
3185
|
+
}), [
|
|
3186
|
+
layout,
|
|
3187
|
+
theme,
|
|
3188
|
+
viewport,
|
|
3189
|
+
labels,
|
|
3190
|
+
openReport
|
|
3191
|
+
]);
|
|
3192
|
+
/** The overlay's scene: the base scene plus what the pointer and the panel point at. */
|
|
3193
|
+
const overlayScene = (0, react.useMemo)(() => ({
|
|
3194
|
+
...baseScene,
|
|
3195
|
+
focus: {
|
|
3196
|
+
...openReport === void 0 ? {} : { selected: openReport },
|
|
3197
|
+
...hover.report === void 0 ? {} : { report: hover.report },
|
|
3198
|
+
...hover.frame === void 0 ? {} : { frame: hover.frame }
|
|
3199
|
+
}
|
|
3200
|
+
}), [
|
|
3201
|
+
baseScene,
|
|
3202
|
+
openReport,
|
|
3203
|
+
hover
|
|
3204
|
+
]);
|
|
3205
|
+
(0, react.useEffect)(() => {
|
|
3206
|
+
if (!ready) return;
|
|
3207
|
+
const ratio = globalThis.devicePixelRatio || 1;
|
|
3208
|
+
painter.current?.resize(size.width, size.height, ratio);
|
|
3209
|
+
pathPainter.current?.resize(size.width, size.height, ratio);
|
|
3210
|
+
}, [
|
|
3211
|
+
ready,
|
|
3212
|
+
size,
|
|
3213
|
+
dprTick
|
|
3214
|
+
]);
|
|
3215
|
+
(0, react.useEffect)(() => {
|
|
3216
|
+
const target = painter.current;
|
|
3217
|
+
if (!ready || target === null) return;
|
|
3218
|
+
target.paint(baseScene);
|
|
3219
|
+
}, [
|
|
3220
|
+
ready,
|
|
3221
|
+
baseScene,
|
|
3222
|
+
fontTick
|
|
3223
|
+
]);
|
|
3224
|
+
(0, react.useEffect)(() => {
|
|
3225
|
+
const target = pathPainter.current;
|
|
3226
|
+
if (!ready || target === null) return;
|
|
3227
|
+
target.paintPath(overlayScene);
|
|
3228
|
+
}, [
|
|
3229
|
+
ready,
|
|
3230
|
+
overlayScene,
|
|
3231
|
+
fontTick
|
|
3232
|
+
]);
|
|
3233
|
+
/** Everything under the pointer, topmost first: cards, then the frames holding them. */
|
|
3234
|
+
const targets = (0, react.useMemo)(() => [...[...layout.cards].reverse().map((card) => ({
|
|
3235
|
+
kind: "card",
|
|
3236
|
+
report: card.report,
|
|
3237
|
+
bounds: card.bounds
|
|
3238
|
+
})), ...[...layout.frames].reverse().map((frame) => ({
|
|
3239
|
+
kind: "frame",
|
|
3240
|
+
frame: frame.index,
|
|
3241
|
+
bounds: frame.bounds
|
|
3242
|
+
}))], [layout]);
|
|
3243
|
+
const pick = (0, react.useCallback)((clientX, clientY) => {
|
|
3244
|
+
const box = boxRef.current;
|
|
3245
|
+
if (box === null) return void 0;
|
|
3246
|
+
const rect = box.getBoundingClientRect();
|
|
3247
|
+
const x = (clientX - rect.left - offset.x) / zoom;
|
|
3248
|
+
const y = (clientY - rect.top - offset.y) / zoom;
|
|
3249
|
+
return hitTest(targets, x, y);
|
|
3250
|
+
}, [
|
|
3251
|
+
targets,
|
|
3252
|
+
offset,
|
|
3253
|
+
zoom
|
|
3254
|
+
]);
|
|
3255
|
+
/**
|
|
3256
|
+
* Fill the width, in the richest tier that can afford to.
|
|
3257
|
+
*
|
|
3258
|
+
* The geometry itself lives in the model ({@link fitTransform}): the scale and
|
|
3259
|
+
* the tier are mutually dependent, so the probing belongs where it is pure,
|
|
3260
|
+
* byte-deterministic, and reachable by the assertions. What stays here is the
|
|
3261
|
+
* one thing that needs the DOM — which width to fit into — and the state write.
|
|
3262
|
+
*/
|
|
3263
|
+
const fit = (0, react.useCallback)(() => {
|
|
3264
|
+
const box = boxRef.current;
|
|
3265
|
+
if (box === null) return;
|
|
3266
|
+
const next = fitTransform(payload, reports, box.clientWidth);
|
|
3267
|
+
if (next === void 0) return;
|
|
3268
|
+
fittedTransform.current = true;
|
|
3269
|
+
setZoom(next.scale);
|
|
3270
|
+
setOffset({
|
|
3271
|
+
x: next.offsetX,
|
|
3272
|
+
y: next.offsetY
|
|
3273
|
+
});
|
|
3274
|
+
}, [payload, reports]);
|
|
3275
|
+
/**
|
|
3276
|
+
* The `+`/`−` controls.
|
|
3277
|
+
*
|
|
3278
|
+
* An explicit zoom is the reader's decision, so the fit stops following the box
|
|
3279
|
+
* from here — the same reason the wheel and the drag clear the flag.
|
|
3280
|
+
* @param factor - what to multiply the current zoom by.
|
|
3281
|
+
*/
|
|
3282
|
+
const stepZoom = (0, react.useCallback)((factor) => {
|
|
3283
|
+
fittedTransform.current = false;
|
|
3284
|
+
setZoom((z) => clamp(z * factor, MIN_ZOOM, MAX_ZOOM));
|
|
3285
|
+
}, []);
|
|
3286
|
+
/**
|
|
3287
|
+
* The first view is the fit — the same rule the control runs.
|
|
3288
|
+
*
|
|
3289
|
+
* It used to be a rule of its own (fit the width, never below the card tier,
|
|
3290
|
+
* never above 100%) because the fit of the day — the whole drawing inside the
|
|
3291
|
+
* box — landed around 57%, the compact tier, and opened the tab as a wall of
|
|
3292
|
+
* one-line cards. With the fit now filling the width in the richest tier that
|
|
3293
|
+
* can afford it, that special case has nothing left to correct, and one rule is
|
|
3294
|
+
* one fewer way for the first sight and the control to disagree.
|
|
3295
|
+
*/
|
|
3296
|
+
(0, react.useEffect)(() => {
|
|
3297
|
+
if (fitted.current || size.width === 0 || layout.cards.length === 0) return;
|
|
3298
|
+
fitted.current = true;
|
|
3299
|
+
fit();
|
|
3300
|
+
}, [
|
|
3301
|
+
fit,
|
|
3302
|
+
layout,
|
|
3303
|
+
size
|
|
3304
|
+
]);
|
|
3305
|
+
/**
|
|
3306
|
+
* Follow the box, but only while the box is what chose the view.
|
|
3307
|
+
*
|
|
3308
|
+
* A dock, a split and a fullscreen switch all change the box under a transform
|
|
3309
|
+
* computed for the old one. A drawing left carrying the old offset is clipped by
|
|
3310
|
+
* the new box — it reads as a broken drawing rather than as a preserved view —
|
|
3311
|
+
* so a view the fit chose is fitted again. A reader who zoomed in to read one
|
|
3312
|
+
* card means it, so a view they took over is left exactly where they put it.
|
|
3313
|
+
*
|
|
3314
|
+
* The box is compared by value, not by identity: the observer hands over a fresh
|
|
3315
|
+
* object on every measure, and a re-measure of the same box is not a resize.
|
|
3316
|
+
*/
|
|
3317
|
+
(0, react.useEffect)(() => {
|
|
3318
|
+
const previous = measured.current;
|
|
3319
|
+
measured.current = size;
|
|
3320
|
+
if (size.width === 0 || previous.width === 0) return;
|
|
3321
|
+
if (previous.width === size.width && previous.height === size.height) return;
|
|
3322
|
+
if (fittedTransform.current) fit();
|
|
3323
|
+
}, [fit, size]);
|
|
3324
|
+
(0, react.useEffect)(() => {
|
|
3325
|
+
const box = boxRef.current;
|
|
3326
|
+
if (box === null) return;
|
|
3327
|
+
const onWheel = (event) => {
|
|
3328
|
+
event.preventDefault();
|
|
3329
|
+
fittedTransform.current = false;
|
|
3330
|
+
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? box.clientHeight : 1;
|
|
3331
|
+
const dy = event.deltaY * unit;
|
|
3332
|
+
const dx = event.deltaX * unit;
|
|
3333
|
+
const previous = offsetRef.current;
|
|
3334
|
+
if (event.ctrlKey || event.metaKey) {
|
|
3335
|
+
const rect = box.getBoundingClientRect();
|
|
3336
|
+
const px = event.clientX - rect.left;
|
|
3337
|
+
const py = event.clientY - rect.top;
|
|
3338
|
+
const current = zoomRef.current;
|
|
3339
|
+
const next = clamp(current * Math.exp(-dy * WHEEL_ZOOM_RATE), MIN_ZOOM, MAX_ZOOM);
|
|
3340
|
+
const ratio = next / current;
|
|
3341
|
+
setOffset({
|
|
3342
|
+
x: px - (px - previous.x) * ratio,
|
|
3343
|
+
y: py - (py - previous.y) * ratio
|
|
3344
|
+
});
|
|
3345
|
+
setZoom(next);
|
|
3346
|
+
return;
|
|
3347
|
+
}
|
|
3348
|
+
const drawing = layoutRef.current;
|
|
3349
|
+
const zoom = zoomRef.current;
|
|
3350
|
+
const width = drawing.width * zoom;
|
|
3351
|
+
const height = drawing.height * zoom;
|
|
3352
|
+
const overflowX = width - box.clientWidth;
|
|
3353
|
+
const overflowY = height - box.clientHeight;
|
|
3354
|
+
const panX = event.shiftKey && dx === 0 ? dy : dx;
|
|
3355
|
+
const panY = event.shiftKey ? 0 : dy;
|
|
3356
|
+
setOffset({
|
|
3357
|
+
x: overflowX > 0 ? clamp(previous.x - panX, box.clientWidth - width - EDGE_MARGIN, EDGE_MARGIN) : previous.x,
|
|
3358
|
+
y: overflowY > 0 ? clamp(previous.y - panY, box.clientHeight - height - EDGE_MARGIN, EDGE_MARGIN) : previous.y
|
|
3359
|
+
});
|
|
3360
|
+
};
|
|
3361
|
+
box.addEventListener("wheel", onWheel, { passive: false });
|
|
3362
|
+
return () => {
|
|
3363
|
+
box.removeEventListener("wheel", onWheel);
|
|
3364
|
+
};
|
|
3365
|
+
}, []);
|
|
3366
|
+
/**
|
|
3367
|
+
* Drag to pan, click to open. Distinguishing them by distance travelled.
|
|
3368
|
+
*
|
|
3369
|
+
* The pointer is captured on press, so a drag keeps following it outside the box
|
|
3370
|
+
* instead of being abandoned at the edge (a mouse has no implicit capture; touch
|
|
3371
|
+
* does). Only the primary pointer is followed, so a second finger cannot reset the
|
|
3372
|
+
* drag's origin halfway through a pan.
|
|
3373
|
+
*/
|
|
3374
|
+
const onPointerDown = (0, react.useCallback)((event) => {
|
|
3375
|
+
if (event.button !== 0 || !event.isPrimary) return;
|
|
3376
|
+
event.currentTarget.setPointerCapture(event.pointerId);
|
|
3377
|
+
drag.current = {
|
|
3378
|
+
x: event.clientX,
|
|
3379
|
+
y: event.clientY,
|
|
3380
|
+
offsetX: offset.x,
|
|
3381
|
+
offsetY: offset.y,
|
|
3382
|
+
moved: false,
|
|
3383
|
+
pointerId: event.pointerId
|
|
3384
|
+
};
|
|
3385
|
+
}, [offset]);
|
|
3386
|
+
const onPointerMove = (0, react.useCallback)((event) => {
|
|
3387
|
+
if (!event.isPrimary) return;
|
|
3388
|
+
const pressing = drag.current;
|
|
3389
|
+
if (pressing !== null) {
|
|
3390
|
+
const dx = event.clientX - pressing.x;
|
|
3391
|
+
const dy = event.clientY - pressing.y;
|
|
3392
|
+
if (dx * dx + dy * dy > DRAG_SLOP * DRAG_SLOP) pressing.moved = true;
|
|
3393
|
+
if (pressing.moved) {
|
|
3394
|
+
fittedTransform.current = false;
|
|
3395
|
+
setOffset({
|
|
3396
|
+
x: pressing.offsetX + dx,
|
|
3397
|
+
y: pressing.offsetY + dy
|
|
3398
|
+
});
|
|
3399
|
+
return;
|
|
3400
|
+
}
|
|
3401
|
+
}
|
|
3402
|
+
const hit = pick(event.clientX, event.clientY);
|
|
3403
|
+
const next = hit?.kind === "card" && hit.report !== void 0 ? { report: hit.report } : hit?.kind === "frame" && hit.frame !== void 0 ? { frame: hit.frame } : {};
|
|
3404
|
+
setHover((current) => sameHover(current, next) ? current : next);
|
|
3405
|
+
}, [pick]);
|
|
3406
|
+
const onPointerUp = (0, react.useCallback)((event) => {
|
|
3407
|
+
if (!event.isPrimary) return;
|
|
3408
|
+
const pressing = drag.current;
|
|
3409
|
+
drag.current = null;
|
|
3410
|
+
if (pressing === null || pressing.moved) return;
|
|
3411
|
+
const target = event.target;
|
|
3412
|
+
if (target instanceof Element && target.closest("button") !== null) return;
|
|
3413
|
+
const hit = pick(event.clientX, event.clientY);
|
|
3414
|
+
if (hit?.kind === "card" && hit.report !== void 0) onOpen(hit.report);
|
|
3415
|
+
}, [pick, onOpen]);
|
|
3416
|
+
const onPointerLeave = (0, react.useCallback)((event) => {
|
|
3417
|
+
const pressing = drag.current;
|
|
3418
|
+
if (pressing !== null && event.currentTarget.hasPointerCapture(pressing.pointerId)) return;
|
|
3419
|
+
drag.current = null;
|
|
3420
|
+
setHover((current) => sameHover(current, {}) ? current : {});
|
|
3421
|
+
}, []);
|
|
3422
|
+
/** A cancelled pointer will never deliver a release, so the drag ends here. */
|
|
3423
|
+
const onPointerCancel = (0, react.useCallback)((event) => {
|
|
3424
|
+
if (!event.isPrimary) return;
|
|
3425
|
+
drag.current = null;
|
|
3426
|
+
}, []);
|
|
3427
|
+
const visibleCards = (0, react.useMemo)(() => {
|
|
3428
|
+
const left = -offset.x / zoom;
|
|
3429
|
+
const top = -offset.y / zoom;
|
|
3430
|
+
const right = (size.width - offset.x) / zoom;
|
|
3431
|
+
const bottom = (size.height - offset.y) / zoom;
|
|
3432
|
+
return layout.cards.filter((card) => card.bounds.x < right && left < card.bounds.x + card.bounds.width && card.bounds.y < bottom && top < card.bounds.y + card.bounds.height);
|
|
3433
|
+
}, [
|
|
3434
|
+
layout,
|
|
3435
|
+
offset,
|
|
3436
|
+
zoom,
|
|
3437
|
+
size
|
|
3438
|
+
]);
|
|
3439
|
+
const percent = Math.round(zoom * 100);
|
|
3440
|
+
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3441
|
+
style: {
|
|
3442
|
+
display: "flex",
|
|
3443
|
+
flexDirection: "column",
|
|
3444
|
+
gap: "6px",
|
|
3445
|
+
...fill ? fillRoot : {}
|
|
3446
|
+
},
|
|
3447
|
+
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3448
|
+
style: controls,
|
|
3449
|
+
children: [
|
|
3450
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3451
|
+
variant: "outline",
|
|
3452
|
+
size: "sm",
|
|
3453
|
+
"aria-label": t("cardgraph.zoomOut"),
|
|
3454
|
+
onClick: () => {
|
|
3455
|
+
stepZoom(1 / ZOOM_STEP);
|
|
3456
|
+
},
|
|
3457
|
+
children: "−"
|
|
3458
|
+
}),
|
|
3459
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3460
|
+
style: percentStyle,
|
|
3461
|
+
children: t("cardgraph.zoom", { percent })
|
|
3462
|
+
}),
|
|
3463
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3464
|
+
variant: "outline",
|
|
3465
|
+
size: "sm",
|
|
3466
|
+
"aria-label": t("cardgraph.zoomIn"),
|
|
3467
|
+
onClick: () => {
|
|
3468
|
+
stepZoom(ZOOM_STEP);
|
|
3469
|
+
},
|
|
3470
|
+
children: "+"
|
|
3471
|
+
}),
|
|
3472
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
|
|
3473
|
+
variant: "outline",
|
|
3474
|
+
size: "sm",
|
|
3475
|
+
onClick: fit,
|
|
3476
|
+
children: t("cardgraph.fit")
|
|
3477
|
+
}),
|
|
3478
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardgraphLegend, { t }),
|
|
3479
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
|
|
3480
|
+
style: percentStyle,
|
|
3481
|
+
children: t("cardgraph.summary", {
|
|
3482
|
+
frames: layout.frames.length,
|
|
3483
|
+
cards: layout.cards.length
|
|
3484
|
+
})
|
|
3485
|
+
})
|
|
3486
|
+
]
|
|
3487
|
+
}), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
|
|
3488
|
+
ref: boxRef,
|
|
3489
|
+
onPointerDown,
|
|
3490
|
+
onPointerMove,
|
|
3491
|
+
onPointerUp,
|
|
3492
|
+
onPointerCancel,
|
|
3493
|
+
onPointerLeave,
|
|
3494
|
+
title: t("cardgraph.gestureHint"),
|
|
3495
|
+
style: {
|
|
3496
|
+
...box,
|
|
3497
|
+
...fill ? fillBox : { height: `${VIEW_HEIGHT}px` },
|
|
3498
|
+
cursor: drag.current?.moved === true ? "grabbing" : hover.report === void 0 ? "grab" : "pointer"
|
|
3499
|
+
},
|
|
3500
|
+
children: [
|
|
3501
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
|
|
3502
|
+
ref: baseRef,
|
|
3503
|
+
role: "img",
|
|
3504
|
+
"aria-label": t("cardgraph.summary", {
|
|
3505
|
+
frames: layout.frames.length,
|
|
3506
|
+
cards: layout.cards.length
|
|
3507
|
+
}),
|
|
3508
|
+
style: layer
|
|
3509
|
+
}),
|
|
3510
|
+
/* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
|
|
3511
|
+
ref: pathRef,
|
|
3512
|
+
"aria-hidden": "true",
|
|
3513
|
+
style: layer
|
|
3514
|
+
}),
|
|
3515
|
+
visibleCards.map((card) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
|
|
3516
|
+
type: "button",
|
|
3517
|
+
"aria-label": `${card.report} · ${card.subject}`,
|
|
3518
|
+
title: idHint(card.report),
|
|
3519
|
+
onClick: () => {
|
|
3520
|
+
onOpen(card.report);
|
|
3521
|
+
},
|
|
3522
|
+
onFocus: () => {
|
|
3523
|
+
setHover({ report: card.report });
|
|
3524
|
+
},
|
|
3525
|
+
onBlur: () => {
|
|
3526
|
+
setHover({});
|
|
3527
|
+
},
|
|
3528
|
+
style: {
|
|
3529
|
+
position: "absolute",
|
|
3530
|
+
left: `${card.bounds.x * zoom + offset.x}px`,
|
|
3531
|
+
top: `${card.bounds.y * zoom + offset.y}px`,
|
|
3532
|
+
width: `${card.bounds.width * zoom}px`,
|
|
3533
|
+
height: `${card.bounds.height * zoom}px`,
|
|
3534
|
+
padding: 0,
|
|
3535
|
+
border: 0,
|
|
3536
|
+
background: "transparent",
|
|
3537
|
+
cursor: "pointer"
|
|
3538
|
+
}
|
|
3539
|
+
}, card.report))
|
|
3540
|
+
]
|
|
3541
|
+
})]
|
|
789
3542
|
});
|
|
790
3543
|
}
|
|
3544
|
+
const controls = {
|
|
3545
|
+
display: "flex",
|
|
3546
|
+
alignItems: "center",
|
|
3547
|
+
gap: "8px",
|
|
3548
|
+
flexWrap: "wrap"
|
|
3549
|
+
};
|
|
3550
|
+
const percentStyle = {
|
|
3551
|
+
fontSize: "var(--dsw-font-xxxs-11, 11px)",
|
|
3552
|
+
color: "var(--dsw-alias-label-caption)",
|
|
3553
|
+
fontVariantNumeric: "tabular-nums"
|
|
3554
|
+
};
|
|
3555
|
+
const box = {
|
|
3556
|
+
position: "relative",
|
|
3557
|
+
overflow: "hidden",
|
|
3558
|
+
borderRadius: "6px",
|
|
3559
|
+
border: "1px solid var(--dsw-alias-border-l1)",
|
|
3560
|
+
touchAction: "none"
|
|
3561
|
+
};
|
|
3562
|
+
/**
|
|
3563
|
+
* A filling host owns the height: the root takes the whole body and lets the
|
|
3564
|
+
* measured box keep whatever the toolbar left. `minHeight` is what stops a flex
|
|
3565
|
+
* child from refusing to shrink, which would push the toolbar off the top of a
|
|
3566
|
+
* short panel instead of shrinking the drawing.
|
|
3567
|
+
*/
|
|
3568
|
+
const fillRoot = {
|
|
3569
|
+
flex: "1 1 auto",
|
|
3570
|
+
height: "100%",
|
|
3571
|
+
minHeight: "0"
|
|
3572
|
+
};
|
|
3573
|
+
const fillBox = {
|
|
3574
|
+
flex: "1 1 auto",
|
|
3575
|
+
minHeight: "0"
|
|
3576
|
+
};
|
|
3577
|
+
const layer = {
|
|
3578
|
+
position: "absolute",
|
|
3579
|
+
inset: "0",
|
|
3580
|
+
display: "block"
|
|
3581
|
+
};
|
|
791
3582
|
//#endregion
|
|
792
|
-
//#region src/client/
|
|
3583
|
+
//#region src/client/sidebar-tab.ts
|
|
793
3584
|
/**
|
|
794
|
-
*
|
|
3585
|
+
* The card canvas as a right-Sidebar tab — stage one and stage two of the tab
|
|
3586
|
+
* system.
|
|
3587
|
+
*
|
|
3588
|
+
* The right column belongs to the product
|
|
3589
|
+
* (`@deepseek-ai/dsh-client-ui-sidebar-right`), and a type from outside reaches
|
|
3590
|
+
* it through **cordis services**, never through imports: the client bundle's
|
|
3591
|
+
* purity gate rejects a cross-plugin value import, and that package's types are
|
|
3592
|
+
* not part of a third-party plugin's resolvable dependency set anyway. So the
|
|
3593
|
+
* two faces this module needs are asserted structurally here, exactly as
|
|
3594
|
+
* `client/index.ts` already asserts the `slots` and `locale` faces.
|
|
3595
|
+
*
|
|
3596
|
+
* ## Why the registry is injected rather than read
|
|
3597
|
+
*
|
|
3598
|
+
* `sidebarRightTabs` is published by that package's own `apply`. Cordis has no
|
|
3599
|
+
* optional-dependency form: a key on this plugin's `inject` list is a
|
|
3600
|
+
* REQUIREMENT, and a missing one parks this plugin's fiber — on the web boot
|
|
3601
|
+
* that is a white page, not a degraded view. `ctx.get` fails the other way: it
|
|
3602
|
+
* reads the registry at this instant, so a host that publishes it a moment later
|
|
3603
|
+
* would leave the tab silently unregistered forever. `ctx.inject` is the one
|
|
3604
|
+
* form that is both optional and late — the callback runs when the service
|
|
3605
|
+
* actually appears, and again if it comes back.
|
|
3606
|
+
*
|
|
3607
|
+
* ## Why the guide entry is not optional in practice
|
|
795
3608
|
*
|
|
796
|
-
*
|
|
797
|
-
*
|
|
798
|
-
*
|
|
3609
|
+
* The strip's `+` control only ever re-opens the guide page, and the guide lists
|
|
3610
|
+
* registered types through exactly the entries they declare. Without one, this
|
|
3611
|
+
* tab could only ever be opened by code.
|
|
799
3612
|
*
|
|
800
|
-
*
|
|
3613
|
+
* ## What the body draws
|
|
3614
|
+
*
|
|
3615
|
+
* The drawing the conversation tab used to carry, filling the panel instead of
|
|
3616
|
+
* sitting in a fixed 420px box — the panel owns its height, and it can be split
|
|
3617
|
+
* or taken fullscreen, which is most of the reason to put a drawing in this
|
|
3618
|
+
* column at all. Picking a card opens that report's detail underneath, through
|
|
3619
|
+
* the same view the conversation tab's rows use, so one report cannot read
|
|
3620
|
+
* differently in the two places.
|
|
3621
|
+
*
|
|
3622
|
+
* @module dsh-report-ledger/client/sidebar-tab
|
|
801
3623
|
*/
|
|
802
|
-
/**
|
|
803
|
-
const
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
"view.detail.pending": "仍在等待送达:{targets}",
|
|
818
|
-
"view.detail.body": "正文",
|
|
819
|
-
"view.detail.path": "账本文件",
|
|
820
|
-
"row.hops": "{count} 跳",
|
|
821
|
-
"row.authors": "{count} 位共写",
|
|
822
|
-
"row.filterByTask": "只看任务「{task}」",
|
|
823
|
-
"filter.all": "全部",
|
|
824
|
-
"filter.open": "进行中",
|
|
825
|
-
"filter.acked": "已回执",
|
|
826
|
-
"filter.closed": "已结案",
|
|
827
|
-
"filter.search": "搜索主题、编号、会话或收发…",
|
|
828
|
-
"filter.clear": "清除",
|
|
829
|
-
"filter.showing": "显示 {visible}/{total} 份汇报",
|
|
830
|
-
"filter.none": "没有符合当前筛选的汇报。",
|
|
831
|
-
"filter.hint": "状态筛选只作用于汇报;搜索会同时匹配会话标题与编号。",
|
|
832
|
-
"basis.created": "按创建",
|
|
833
|
-
"basis.updated": "按最近活动",
|
|
834
|
-
"basis.hint": "汇报按创建时间还是按最后一次活动排入时间线;会话始终按创建时间。",
|
|
835
|
-
"detail.thread": "线程",
|
|
836
|
-
"detail.parent": "上溯",
|
|
837
|
-
"detail.children": "下递",
|
|
838
|
-
"detail.openLinked": "打开 {report}",
|
|
839
|
-
"detail.outside": "{report} 不在当前会话树的列表里(它可能属于另一条线);正文与路径仍可读取。",
|
|
840
|
-
"detail.filteredOut": "{report} 在当前筛选下被隐藏了(用「清除」恢复)。",
|
|
841
|
-
"detail.backToList": "回到列表",
|
|
842
|
-
"detail.bodyTruncated": "正文过长,此处只显示开头 {chars} 字;完整内容见账本文件。"
|
|
3624
|
+
/** This type's identity in the tab system: unique, and the key both seats use. */
|
|
3625
|
+
const TAB_ID = "dsh-report-ledger/canvas";
|
|
3626
|
+
/**
|
|
3627
|
+
* What `openTab(kind)` names.
|
|
3628
|
+
*
|
|
3629
|
+
* Deliberately not derived from {@link TAB_ID}: the registry is looked up BY
|
|
3630
|
+
* kind, and deriving one name from the other is a mistake the ecosystem has
|
|
3631
|
+
* already paid for — a registration that says one thing while its opener says
|
|
3632
|
+
* another only fails at click time.
|
|
3633
|
+
*/
|
|
3634
|
+
const TAB_KIND = "ledger-canvas";
|
|
3635
|
+
/** The sentences that stand in for a drawing that cannot be made yet. */
|
|
3636
|
+
const muted = {
|
|
3637
|
+
padding: "12px",
|
|
3638
|
+
color: "var(--dsw-alias-label-caption)"
|
|
843
3639
|
};
|
|
844
|
-
/**
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
"view.detail.loading": "Reading the transfer path…",
|
|
857
|
-
"view.detail.route": "Transfer path",
|
|
858
|
-
"view.detail.routeEmpty": "(no hops recorded yet)",
|
|
859
|
-
"view.detail.pending": "Still owed to: {targets}",
|
|
860
|
-
"view.detail.body": "Body",
|
|
861
|
-
"view.detail.path": "Ledger file",
|
|
862
|
-
"row.hops": "{count} hops",
|
|
863
|
-
"row.authors": "{count} authors",
|
|
864
|
-
"row.filterByTask": "Show only task \"{task}\"",
|
|
865
|
-
"filter.all": "All",
|
|
866
|
-
"filter.open": "Open",
|
|
867
|
-
"filter.acked": "Acked",
|
|
868
|
-
"filter.closed": "Closed",
|
|
869
|
-
"filter.search": "Search subject, id, session, or people…",
|
|
870
|
-
"filter.clear": "Clear",
|
|
871
|
-
"filter.showing": "Showing {visible}/{total} reports",
|
|
872
|
-
"filter.none": "No reports match the current filter.",
|
|
873
|
-
"filter.hint": "The status filter narrows reports only; search matches every row, including session titles and ids.",
|
|
874
|
-
"basis.created": "By creation",
|
|
875
|
-
"basis.updated": "By activity",
|
|
876
|
-
"basis.hint": "Whether reports are placed at creation or at their latest activity; sessions always keep their creation time.",
|
|
877
|
-
"detail.thread": "Thread",
|
|
878
|
-
"detail.parent": "Answers",
|
|
879
|
-
"detail.children": "Answered by",
|
|
880
|
-
"detail.openLinked": "Open {report}",
|
|
881
|
-
"detail.outside": "{report} is not in this session tree's list (it may belong to another line); its body and path are still readable.",
|
|
882
|
-
"detail.filteredOut": "{report} is hidden by the current filter (use Clear to restore it).",
|
|
883
|
-
"detail.backToList": "Back to the list",
|
|
884
|
-
"detail.bodyTruncated": "Body is long, so only the first {chars} characters are shown here; the ledger file has the whole thing."
|
|
3640
|
+
/**
|
|
3641
|
+
* The canvas over the open report's detail.
|
|
3642
|
+
*
|
|
3643
|
+
* The split is even on purpose: the drawing keeps half the panel while a report
|
|
3644
|
+
* is read, and all of it otherwise. A reader who wants more of either has the
|
|
3645
|
+
* panel's own fullscreen and split rather than a second control here.
|
|
3646
|
+
*/
|
|
3647
|
+
const stack = {
|
|
3648
|
+
display: "flex",
|
|
3649
|
+
flexDirection: "column",
|
|
3650
|
+
height: "100%",
|
|
3651
|
+
minHeight: "0"
|
|
885
3652
|
};
|
|
3653
|
+
/** The drawing alone: it takes whatever the panel has. */
|
|
3654
|
+
const stackFull = {
|
|
3655
|
+
flex: "1 1 auto",
|
|
3656
|
+
minHeight: "0"
|
|
3657
|
+
};
|
|
3658
|
+
const stackCanvas = {
|
|
3659
|
+
flex: "1 1 50%",
|
|
3660
|
+
minHeight: "0"
|
|
3661
|
+
};
|
|
3662
|
+
const stackDetail = {
|
|
3663
|
+
flex: "1 1 50%",
|
|
3664
|
+
minHeight: "0",
|
|
3665
|
+
overflow: "hidden"
|
|
3666
|
+
};
|
|
3667
|
+
/**
|
|
3668
|
+
* Bind the tab body to the plugin's copy.
|
|
3669
|
+
*
|
|
3670
|
+
* The body is the *second* registration and carries the runtime face: a
|
|
3671
|
+
* component, receiving the session it was opened in — which is the only input
|
|
3672
|
+
* the drawing needs, since the ledger is read from the session tree's root.
|
|
3673
|
+
* @param t - namespace-bound translate, read on every render.
|
|
3674
|
+
* @returns the tab body component.
|
|
3675
|
+
*/
|
|
3676
|
+
function bodyFor(t) {
|
|
3677
|
+
return function LedgerCanvasBody(props) {
|
|
3678
|
+
const { payload, error } = useTimeline(typeof props.sessionId === "string" ? props.sessionId : "");
|
|
3679
|
+
const [openReport, setOpenReport] = (0, react.useState)(void 0);
|
|
3680
|
+
const { detail, error: detailError } = useReportDetail(openReport, payload?.generatedAt ?? 0);
|
|
3681
|
+
const rows = (0, react.useMemo)(() => payload === void 0 ? [] : buildRows(payload), [payload]);
|
|
3682
|
+
const reports = (0, react.useMemo)(() => rows.flatMap((row) => row.kind === "report" ? [row.front] : []), [rows]);
|
|
3683
|
+
const inList = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Set() : reportIds(payload), [payload]);
|
|
3684
|
+
const titles = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : sessionTitles(payload), [payload]);
|
|
3685
|
+
const idHint = (0, react.useCallback)((id) => {
|
|
3686
|
+
const title = titles.get(id);
|
|
3687
|
+
return title === void 0 ? id : `${title} · ${id}`;
|
|
3688
|
+
}, [titles]);
|
|
3689
|
+
const onOpen = (0, react.useCallback)((report) => {
|
|
3690
|
+
setOpenReport((current) => current === report ? void 0 : report);
|
|
3691
|
+
}, []);
|
|
3692
|
+
if (error !== void 0) return (0, react.createElement)("div", { style: muted }, t("view.error", { code: error }));
|
|
3693
|
+
if (payload === void 0) return (0, react.createElement)("div", { style: muted }, t("view.loading"));
|
|
3694
|
+
if (reports.length === 0) return (0, react.createElement)("div", { style: muted }, t("view.empty"));
|
|
3695
|
+
const canvasCell = (0, react.createElement)("div", { style: openReport === void 0 ? stackFull : stackCanvas }, (0, react.createElement)(CardgraphView, {
|
|
3696
|
+
payload,
|
|
3697
|
+
reports,
|
|
3698
|
+
t,
|
|
3699
|
+
...openReport === void 0 ? {} : { openReport },
|
|
3700
|
+
onOpen,
|
|
3701
|
+
idHint,
|
|
3702
|
+
fill: true
|
|
3703
|
+
}));
|
|
3704
|
+
if (openReport === void 0) return (0, react.createElement)("div", { style: stack }, canvasCell);
|
|
3705
|
+
return (0, react.createElement)("div", { style: stack }, canvasCell, (0, react.createElement)("div", { style: stackDetail }, (0, react.createElement)(ReportDetailView, {
|
|
3706
|
+
report: openReport,
|
|
3707
|
+
detail,
|
|
3708
|
+
error: detailError,
|
|
3709
|
+
t,
|
|
3710
|
+
idHint,
|
|
3711
|
+
variant: "pane",
|
|
3712
|
+
onOpenReport: setOpenReport,
|
|
3713
|
+
inList: inList.has(openReport),
|
|
3714
|
+
visibleInFilter: true,
|
|
3715
|
+
onClose: () => {
|
|
3716
|
+
setOpenReport(void 0);
|
|
3717
|
+
}
|
|
3718
|
+
})));
|
|
3719
|
+
};
|
|
3720
|
+
}
|
|
3721
|
+
/**
|
|
3722
|
+
* Contribute the canvas tab: its type, its body, and its guide capsule.
|
|
3723
|
+
* @param ctx - the client context (slots, plus the optional registry service).
|
|
3724
|
+
* @param t - namespace-bound translate, read fresh on every label call.
|
|
3725
|
+
*/
|
|
3726
|
+
function installSidebarTab(ctx, t) {
|
|
3727
|
+
ctx.inject(["sidebarRightTabs"], (scoped) => {
|
|
3728
|
+
const tabs = scoped.get("sidebarRightTabs");
|
|
3729
|
+
if (tabs === void 0) return;
|
|
3730
|
+
scoped.effect(() => {
|
|
3731
|
+
const disposeType = tabs.register({
|
|
3732
|
+
id: TAB_ID,
|
|
3733
|
+
kind: TAB_KIND,
|
|
3734
|
+
priority: "extension",
|
|
3735
|
+
title: () => t("sidebar.chip"),
|
|
3736
|
+
guide: [{
|
|
3737
|
+
id: "canvas",
|
|
3738
|
+
order: 15,
|
|
3739
|
+
title: () => t("sidebar.guideTitle"),
|
|
3740
|
+
description: () => t("sidebar.guideDescription")
|
|
3741
|
+
}]
|
|
3742
|
+
});
|
|
3743
|
+
const disposeBody = ctx.slots.inject("sidebar.right.pane.tab", () => ctx.slots.register({
|
|
3744
|
+
name: "sidebar.right.pane.tab",
|
|
3745
|
+
key: TAB_ID
|
|
3746
|
+
}, bodyFor(t)));
|
|
3747
|
+
return () => {
|
|
3748
|
+
disposeBody();
|
|
3749
|
+
disposeType();
|
|
3750
|
+
};
|
|
3751
|
+
}, "report-ledger: canvas tab");
|
|
3752
|
+
});
|
|
3753
|
+
}
|
|
886
3754
|
//#endregion
|
|
887
3755
|
//#region src/client/index.ts
|
|
888
3756
|
/**
|
|
@@ -917,7 +3785,9 @@ window.__ModuleLoader__.load({
|
|
|
917
3785
|
zh,
|
|
918
3786
|
en
|
|
919
3787
|
}), "report-ledger: dictionaries");
|
|
3788
|
+
ctx.effect(() => installStyles(), "report-ledger: adapter styles");
|
|
920
3789
|
const t = ctx.locale.bind(NS);
|
|
3790
|
+
installSidebarTab(ctx, t);
|
|
921
3791
|
ctx.slots.inject("conversation.view", () => ctx.slots.register({
|
|
922
3792
|
name: "conversation.view",
|
|
923
3793
|
id: VIEW_ID,
|