dsh-report-ledger 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -5,9 +5,24 @@ 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
+ /**
11
26
  * Build the merged, time-ordered row list.
12
27
  *
13
28
  * Sessions are placed at their creation time and reports at theirs, so the list
@@ -40,9 +55,79 @@ window.__ModuleLoader__.load({
40
55
  depth: depth.get(front.from) ?? 0,
41
56
  front
42
57
  });
43
- rows.sort((left, right) => left.at - right.at || (left.kind === right.kind ? 0 : left.kind === "session" ? -1 : 1));
58
+ rows.sort(byTime);
44
59
  return rows;
45
60
  }
61
+ /**
62
+ * Shorten a session id so a digest row can be read.
63
+ *
64
+ * A session id is 36 characters and a report's recipients are a list of them, so
65
+ * an unabbreviated meta line is mostly uuid and carries almost no information a
66
+ * human can use. Eight characters of the leading segment stay unique within one
67
+ * collaboration and, unlike a session title, are bounded: titles are the first
68
+ * prompt of a session ("You are doing READ-ONLY research"), so swapping ids for
69
+ * titles trades one unreadable line for another.
70
+ *
71
+ * This is a DENSE-ROW display only. The detail panel keeps full ids, because
72
+ * that is the view somebody copies an id out of, and the tooltip on every
73
+ * shortened id carries the full value.
74
+ * @param id - the session id.
75
+ * @param length - how many characters to keep.
76
+ * @returns the shortened id.
77
+ */
78
+ function shortId(id, length = 8) {
79
+ const bare = id.startsWith("session-") ? id.slice(8) : id;
80
+ return bare.length <= length ? bare : bare.slice(0, length);
81
+ }
82
+ /**
83
+ * Display titles for the sessions in one payload, keyed by session id.
84
+ *
85
+ * Titles are resolved only for the subtree, so an id from another tree simply
86
+ * has none — which is why every caller must fall back to the shortened id
87
+ * rather than assuming a name exists.
88
+ * @param payload - the endpoint payload.
89
+ * @returns the titles that were resolved.
90
+ */
91
+ function sessionTitles(payload) {
92
+ const titles = /* @__PURE__ */ new Map();
93
+ for (const node of payload.sessions) if (typeof node.title === "string" && node.title !== "") titles.set(node.id, node.title);
94
+ return titles;
95
+ }
96
+ /**
97
+ * Make sure the open report has a row, and put it where it belongs.
98
+ *
99
+ * The detail panel is anchored to a report row, so the open report must HAVE
100
+ * one. Two real user paths would otherwise leave the panel with nowhere to
101
+ * render: a thread link pointing at a report outside this tree's payload, and
102
+ * any filter applied while a report is open. In the first case the panel is also
103
+ * the only place the "not in this tree" notice can appear, so the panel is not
104
+ * hoisted out of the list — a row is synthesized instead.
105
+ *
106
+ * The synthesized row is INSERTED at its own place on the time axis rather than
107
+ * appended. Appending made a report created at 11:12 appear below one created at
108
+ * 14:30, which reads as a broken sort at exactly the moment the user is trying
109
+ * to understand why something is missing.
110
+ * @param kept - the rows that survived the filter.
111
+ * @param front - the digest of the open report.
112
+ * @param basis - the active time basis, so the row moves with the rest.
113
+ * @returns the rows, with the open report present exactly once.
114
+ */
115
+ function withSynthesizedRow(kept, front, basis = "created") {
116
+ if (kept.some((row) => row.kind === "report" && row.front.report === front.report)) return [...kept];
117
+ const row = {
118
+ kind: "report",
119
+ at: basis === "created" ? front.created : front.updated,
120
+ depth: 0,
121
+ front
122
+ };
123
+ const index = kept.findIndex((candidate) => byTime(row, candidate) < 0);
124
+ if (index < 0) return [...kept, row];
125
+ return [
126
+ ...kept.slice(0, index),
127
+ row,
128
+ ...kept.slice(index)
129
+ ];
130
+ }
46
131
  /** Every string a report row can be found by. */
47
132
  function reportHaystack(front) {
48
133
  return [
@@ -54,7 +139,9 @@ window.__ModuleLoader__.load({
54
139
  ...front.cc,
55
140
  ...front.authors,
56
141
  front.task ?? "",
57
- ...front.artifacts
142
+ ...front.artifacts,
143
+ front.worktree?.branch ?? "",
144
+ front.worktree?.path ?? ""
58
145
  ].join("\n").toLowerCase();
59
146
  }
60
147
  /** Every string a session row can be found by. */
@@ -113,6 +200,51 @@ window.__ModuleLoader__.load({
113
200
  return filter.status !== "all" || filter.text.trim() !== "";
114
201
  }
115
202
  /**
203
+ * Index the payload's working-tree lanes by report id.
204
+ *
205
+ * The lanes carry the lease state, which the digests do not: liveness is derived
206
+ * from the hop stream, and the host derives it once rather than each half of the
207
+ * plugin deriving it separately.
208
+ * @param payload - the endpoint payload.
209
+ * @returns report id to lane.
210
+ */
211
+ function laneIndex(payload) {
212
+ return new Map(payload.worktrees.map((lane) => [lane.report, lane]));
213
+ }
214
+ /**
215
+ * How a lease state should read at a glance.
216
+ *
217
+ * A live claim is the only state that means someone is working *now*, so it is
218
+ * the only one that earns a positive tone. A cleared checkout is deliberately
219
+ * plain rather than alarming: the ledger tidying up after itself is not a
220
+ * problem to be surfaced, and giving it a warning tone would teach a reader to
221
+ * ignore warnings.
222
+ */
223
+ const LEASE_TONE = {
224
+ held: "success",
225
+ released: "info",
226
+ reclaimed: "neutral",
227
+ none: "neutral"
228
+ };
229
+ /** The last path segment, for naming a checkout that has no branch. */
230
+ function lastSegment(path) {
231
+ const parts = path.split(/[\\/]/);
232
+ return parts[parts.length - 1] || path;
233
+ }
234
+ /**
235
+ * The short name a checkout is shown under.
236
+ *
237
+ * Taken from the binding rather than from the lane, because a report opened by
238
+ * id can be rendered before it is in the payload's lane list — the binding is
239
+ * always on the row, the lane is not.
240
+ * @param binding - the row's binding, when it has one.
241
+ * @returns the label, or `undefined` when the report is not bound.
242
+ */
243
+ function worktreeLabel(binding) {
244
+ if (binding === void 0) return void 0;
245
+ return binding.branch ?? lastSegment(binding.path);
246
+ }
247
+ /**
116
248
  * Count how many report rows are visible under a filter.
117
249
  * @param rows - every assembled row.
118
250
  * @param filter - the active filter.
@@ -124,44 +256,1975 @@ window.__ModuleLoader__.load({
124
256
  return count;
125
257
  }
126
258
  /**
127
- * The report ids present in one payload.
259
+ * Decide which empty-state message applies.
260
+ *
261
+ * An empty ledger and a filter that matched nothing look identical on screen but
262
+ * mean opposite things: one says "nothing has happened in this tree yet", the
263
+ * other says "you are looking at a subset of what happened". Saying the wrong
264
+ * one makes a working ledger look broken, so the choice is made here — where it
265
+ * is pinned by tests — rather than inline in the view.
266
+ *
267
+ * Counted on REPORT rows only. Session rows always survive a status chip, so
268
+ * counting them would let a tree full of sessions report itself as "you have
269
+ * filtered everything out" while the actual reason is that no report exists.
270
+ *
271
+ * The `no-reports` case deliberately wins over the filter: when the ledger holds
272
+ * nothing, a filter cannot be the reason nothing is on screen, and blaming it
273
+ * would send the reader looking for a filter to clear that they never set.
274
+ * @param payload - the endpoint payload.
275
+ * @param rows - every assembled row.
276
+ * @param filter - the active filter.
277
+ * @returns which message the view should show, if any.
278
+ */
279
+ function emptyState(payload, rows, filter) {
280
+ if (payload.reports.length === 0) return "no-reports";
281
+ if (!isFiltering(filter)) return "none";
282
+ return visibleReports(rows, filter) === 0 ? "filtered-out" : "none";
283
+ }
284
+ /**
285
+ * The report ids present in one payload.
286
+ *
287
+ * A thread link can point outside the subtree — a report answering one opened by
288
+ * an unrelated session, for instance — and the detail endpoint is ledger-scoped,
289
+ * so such a report still opens. Knowing which ids are in the list is what lets
290
+ * the view say so instead of silently showing a card that is not in the timeline.
291
+ * @param payload - the endpoint payload.
292
+ * @returns the ids of every report in the payload.
293
+ */
294
+ function reportIds(payload) {
295
+ const ids = /* @__PURE__ */ new Set();
296
+ for (const front of payload.reports) ids.add(front.report);
297
+ return ids;
298
+ }
299
+ /** Body length above which the detail panel shows only a head. */
300
+ const BODY_DISPLAY_LIMIT = 4e3;
301
+ /** How much of an over-long body is shown. */
302
+ const BODY_HEAD_LIMIT = 1200;
303
+ /**
304
+ * Trim a body for the detail panel.
305
+ *
306
+ * Mirrors what `report_read` already does for the model: the full text stays in
307
+ * the ledger file, and both readers get a bounded view plus a pointer rather than
308
+ * an unbounded paste. Keeping the human view and the model view bounded the same
309
+ * way means neither can be surprised by what the other sees.
310
+ * @param body - the raw body.
311
+ * @param limit - length above which truncation applies.
312
+ * @param head - how many characters to keep when truncating.
313
+ * @returns the display text and whether it was cut.
314
+ */
315
+ function displayBody(body, limit = BODY_DISPLAY_LIMIT, head = BODY_HEAD_LIMIT) {
316
+ if (body.length <= limit) return {
317
+ text: body,
318
+ truncated: false
319
+ };
320
+ return {
321
+ text: body.slice(0, head),
322
+ truncated: true
323
+ };
324
+ }
325
+ /**
326
+ * Close a code fence left hanging by truncation.
327
+ *
328
+ * Cutting Markdown at a character count is not the same operation as cutting
329
+ * plain text: the result can be a document that does not parse as written. The
330
+ * one construct that changes the meaning of everything after it is a code fence,
331
+ * so a body cut inside a fenced block hands the renderer an unterminated one and
332
+ * leaves it to decide where the block ends.
333
+ *
334
+ * The renderer here does cope (measured: a body cut mid-fence renders as one
335
+ * closed code block with no trailing artifact), which is exactly why this is
336
+ * cheap insurance rather than a fix for something visibly broken: the renderer is
337
+ * a shell platform module this plugin does not own, and its leniency is not a
338
+ * contract. Balancing the document costs one line and makes the plugin's output
339
+ * valid Markdown no matter who renders it.
340
+ *
341
+ * Balanced text is returned untouched, so this only ever adds the fence the cut
342
+ * removed. Only backtick fences are counted; `~~~` fences are rare enough in
343
+ * report bodies that guessing which convention the author used would be worse
344
+ * than leaving them alone.
345
+ * @param text - the already-truncated head.
346
+ * @returns the head, with an unclosed fence closed.
347
+ */
348
+ function closeOpenFence(text) {
349
+ let open = false;
350
+ for (const line of text.split("\n")) if (line.trimStart().startsWith("```")) open = !open;
351
+ return open ? `${text}\n\`\`\`` : text;
352
+ }
353
+ //#endregion
354
+ //#region src/client/cardgraph-model.ts
355
+ /**
356
+ * Defaults.
357
+ *
358
+ * A full card is 240x110 — enough for a title, a status line and two lines of
359
+ * subject at a readable size. The chip tier is deliberately tiny: below 0.4 zoom
360
+ * a card is a colour, and drawing readable text there would be a lie about how
361
+ * much of the drawing the reader can actually take in.
362
+ */
363
+ const DEFAULT_CARDGRAPH_OPTIONS = {
364
+ lod: "card",
365
+ cardMinZoom: .8,
366
+ compactMinZoom: .4,
367
+ sizes: {
368
+ card: {
369
+ width: 240,
370
+ height: 110
371
+ },
372
+ compact: {
373
+ width: 160,
374
+ height: 36
375
+ },
376
+ chip: {
377
+ width: 60,
378
+ height: 14
379
+ }
380
+ },
381
+ frameHeaderHeight: 26,
382
+ framePaddingX: 12,
383
+ framePaddingTop: 10,
384
+ framePaddingBottom: 12,
385
+ minFrameWidth: 180,
386
+ minFrameHeight: 96,
387
+ cardGap: 8,
388
+ columnGap: 48,
389
+ frameGap: 24,
390
+ margin: 24
391
+ };
392
+ /**
393
+ * Which tier a zoom level calls for.
394
+ *
395
+ * Thresholds are inclusive on the low end, so 0.8 is a full card and 0.4 is a
396
+ * compact one: a zoom that lands exactly on a step reads as the richer of the
397
+ * two. A zoom that is not a usable number degrades to the cheapest tier instead
398
+ * of producing an infinite or negative box.
399
+ * @param zoom - the current scale.
400
+ * @param options - threshold overrides.
401
+ * @returns the tier to lay out at.
402
+ */
403
+ function lodFor(zoom, options = {}) {
404
+ const opts = {
405
+ ...DEFAULT_CARDGRAPH_OPTIONS,
406
+ ...options
407
+ };
408
+ if (!Number.isFinite(zoom)) return "chip";
409
+ if (zoom >= opts.cardMinZoom) return "card";
410
+ if (zoom >= opts.compactMinZoom) return "compact";
411
+ return "chip";
412
+ }
413
+ /** Whether two rectangles overlap. Touching edges do not count as overlapping. */
414
+ function intersects(a, b) {
415
+ 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;
416
+ }
417
+ /** Whether a point is inside a rectangle. The top-left edges are inside, the bottom-right ones are not. */
418
+ function contains(rect, x, y) {
419
+ return x >= rect.x && x < rect.x + rect.width && y >= rect.y && y < rect.y + rect.height;
420
+ }
421
+ /** Grow a rectangle by `by` on every side. */
422
+ function grow(rect, by) {
423
+ return {
424
+ x: rect.x - by,
425
+ y: rect.y - by,
426
+ width: rect.width + by * 2,
427
+ height: rect.height + by * 2
428
+ };
429
+ }
430
+ /**
431
+ * The topmost item under a point.
432
+ *
433
+ * Free of the DOM on purpose: the canvas has no elements to hit, so picking is a
434
+ * function from a point and an ordered list to an item — and that function is
435
+ * testable here rather than only by clicking in a browser. The caller passes the
436
+ * list **topmost first**, which is what makes "a card wins over the frame holding
437
+ * it" a property of the order rather than of the arithmetic.
438
+ * @param items - candidates, topmost first.
439
+ * @param x - point, in drawing coordinates.
440
+ * @param y - point, in drawing coordinates.
441
+ * @returns the first hit, or `undefined`.
442
+ */
443
+ function hitTest(items, x, y) {
444
+ return items.find((item) => contains(item.bounds, x, y));
445
+ }
446
+ /**
447
+ * The part of the drawing a viewport can see, in drawing coordinates.
448
+ *
449
+ * The inverse of the transform the painter applies, and the input to culling. A
450
+ * broken transform is treated as identity rather than propagated: a scale of zero
451
+ * or a NaN would otherwise turn the visible rect into an infinite or reversed box
452
+ * and either cull everything or draw everything.
453
+ * @param view - the transform and the size of the paint box.
454
+ * @param overscan - drawing units to keep on each side, so a pan does not reveal a
455
+ * blank band before the next frame is painted.
456
+ * @returns the visible rect.
457
+ */
458
+ function worldViewport(view, overscan = 0) {
459
+ const scale = Number.isFinite(view.scale) && view.scale > 0 ? view.scale : 1;
460
+ const width = Number.isFinite(view.width) ? Math.max(0, view.width) : 0;
461
+ const height = Number.isFinite(view.height) ? Math.max(0, view.height) : 0;
462
+ const offsetX = Number.isFinite(view.offsetX) ? view.offsetX : 0;
463
+ const offsetY = Number.isFinite(view.offsetY) ? view.offsetY : 0;
464
+ const rect = {
465
+ x: -offsetX / scale,
466
+ y: -offsetY / scale,
467
+ width: width / scale,
468
+ height: height / scale
469
+ };
470
+ return overscan === 0 ? rect : grow(rect, overscan);
471
+ }
472
+ /**
473
+ * Build the card graph.
474
+ *
475
+ * Frames come from the payload's sessions: column by subtree depth, vertical
476
+ * order by the payload's own order (the DFS pre-order the list indents by), so a
477
+ * session keeps its relative position across the two views. Cards are stacked
478
+ * inside their author's frame **by time**, because a frame is one session's own
479
+ * timeline; the reader who wants one timeline across all sessions has the list
480
+ * below. Only the reports handed in are drawn, so this is a filtered view rather
481
+ * than a second source of truth.
482
+ * @param payload - the endpoint payload, for the session frames.
483
+ * @param reports - the reports to plot; any order, since each frame sorts its own.
484
+ * @param options - geometry and tier overrides.
485
+ * @returns the layout; empty (and zero-sized) when there is nothing to draw.
486
+ */
487
+ function buildCardgraph(payload, reports, options = {}) {
488
+ const opts = {
489
+ ...DEFAULT_CARDGRAPH_OPTIONS,
490
+ ...options
491
+ };
492
+ const size = opts.sizes[opts.lod];
493
+ const frameWidth = Math.max(opts.minFrameWidth, size.width + opts.framePaddingX * 2);
494
+ const sessions = payload.sessions;
495
+ const depths = [...new Set(sessions.map((node) => node.depth))].sort((a, b) => a - b);
496
+ const columnOfDepth = /* @__PURE__ */ new Map();
497
+ for (const [index, depth] of depths.entries()) columnOfDepth.set(depth, index);
498
+ const frameOf = /* @__PURE__ */ new Map();
499
+ const frames = sessions.map((node, index) => {
500
+ frameOf.set(node.id, index);
501
+ return {
502
+ ...node.title === void 0 ? {} : { title: node.title },
503
+ id: node.id,
504
+ shortId: shortId(node.id),
505
+ depth: node.depth,
506
+ column: columnOfDepth.get(node.depth) ?? 0,
507
+ live: node.live,
508
+ external: false,
509
+ cards: 0
510
+ };
511
+ });
512
+ const mentions = (front) => [
513
+ front.from,
514
+ ...front.to,
515
+ ...front.cc,
516
+ ...front.authors
517
+ ];
518
+ const needsExternal = reports.some((front) => mentions(front).some((id) => id !== void 0 && !frameOf.has(id)));
519
+ const externalDepth = sessions.reduce((max, node) => Math.max(max, node.depth), -1) + 1;
520
+ if (needsExternal) frames.push({
521
+ shortId: "",
522
+ depth: externalDepth,
523
+ column: depths.length,
524
+ live: false,
525
+ external: true,
526
+ cards: 0
527
+ });
528
+ const externalIndex = needsExternal ? frames.length - 1 : -1;
529
+ const frameIndexById = (id) => id !== void 0 && frameOf.has(id) ? frameOf.get(id) : externalIndex;
530
+ const buckets = frames.map(() => []);
531
+ for (const front of reports) {
532
+ const target = frameIndexById(front.from);
533
+ if (target >= 0) buckets[target]?.push(front);
534
+ }
535
+ for (const bucket of buckets) bucket.sort((a, b) => a.created - b.created || (a.report < b.report ? -1 : a.report > b.report ? 1 : 0));
536
+ const columnX = (column) => opts.margin + column * (frameWidth + opts.columnGap);
537
+ const frameHeight = (count) => Math.max(opts.minFrameHeight, opts.frameHeaderHeight + opts.framePaddingTop + opts.framePaddingBottom + count * size.height + Math.max(0, count - 1) * opts.cardGap);
538
+ frames.forEach((frame, index) => {
539
+ frame.cards = buckets[index]?.length ?? 0;
540
+ });
541
+ const cursor = /* @__PURE__ */ new Map();
542
+ const fixed = frames.map((frame) => {
543
+ const height = frameHeight(frame.cards);
544
+ const y = cursor.get(frame.column) ?? opts.margin;
545
+ cursor.set(frame.column, y + height + opts.frameGap);
546
+ return {
547
+ x: columnX(frame.column),
548
+ y,
549
+ width: frameWidth,
550
+ height
551
+ };
552
+ });
553
+ const columnCount = depths.length + (needsExternal ? 1 : 0);
554
+ const heights = Array.from({ length: columnCount }, (_unused, column) => {
555
+ const stacked = fixed.filter((_box, index) => frames[index]?.column === column);
556
+ return stacked.length === 0 ? 0 : stacked.reduce((sum, box) => sum + box.height, 0) + (stacked.length - 1) * opts.frameGap;
557
+ });
558
+ const layoutFrames = frames.map((frame, index) => ({
559
+ ...frame.id === void 0 ? {} : { id: frame.id },
560
+ ...frame.title === void 0 ? {} : { title: frame.title },
561
+ index,
562
+ shortId: frame.shortId,
563
+ depth: frame.depth,
564
+ column: frame.column,
565
+ live: frame.live,
566
+ external: frame.external,
567
+ cards: frame.cards,
568
+ bounds: fixed[index]
569
+ }));
570
+ const columns = Array.from({ length: columnCount }, (_unused, column) => ({
571
+ depth: depths[column] ?? externalDepth,
572
+ index: column,
573
+ bounds: {
574
+ x: columnX(column),
575
+ y: opts.margin,
576
+ width: frameWidth,
577
+ height: Math.max(0, heights[column] ?? 0)
578
+ }
579
+ }));
580
+ const cards = [];
581
+ const cardIndexOf = /* @__PURE__ */ new Map();
582
+ layoutFrames.forEach((frame) => {
583
+ (buckets[frame.index] ?? []).forEach((front, slot) => {
584
+ cardIndexOf.set(front.report, cards.length);
585
+ cards.push({
586
+ report: front.report,
587
+ subject: front.subject,
588
+ status: front.status,
589
+ ...front.task === void 0 ? {} : { task: front.task },
590
+ frame: frame.index,
591
+ column: frame.column,
592
+ slot,
593
+ lod: opts.lod,
594
+ bounds: {
595
+ x: frame.bounds.x + opts.framePaddingX,
596
+ y: frame.bounds.y + opts.frameHeaderHeight + opts.framePaddingTop + slot * (size.height + opts.cardGap),
597
+ width: size.width,
598
+ height: size.height
599
+ }
600
+ });
601
+ });
602
+ });
603
+ const cardEnd = (index, side) => {
604
+ const box = cards[index]?.bounds ?? {
605
+ x: 0,
606
+ y: 0,
607
+ width: 0,
608
+ height: 0
609
+ };
610
+ return {
611
+ kind: "card",
612
+ id: cards[index]?.report,
613
+ index,
614
+ side,
615
+ x: side === "right" ? box.x + box.width : side === "left" ? box.x : box.x + box.width / 2,
616
+ y: side === "bottom" ? box.y + box.height : side === "top" ? box.y : box.y + box.height / 2
617
+ };
618
+ };
619
+ const frameEnd = (index, side) => {
620
+ const box = fixed[index] ?? {
621
+ x: 0,
622
+ y: 0,
623
+ width: 0,
624
+ height: 0
625
+ };
626
+ return {
627
+ kind: "frame",
628
+ ...frames[index]?.id === void 0 ? {} : { id: frames[index]?.id },
629
+ index,
630
+ side,
631
+ x: side === "right" ? box.x + box.width : box.x,
632
+ y: box.y + box.height / 2
633
+ };
634
+ };
635
+ const externalBetween = (a, b) => externalIndex >= 0 && (a === externalIndex || b === externalIndex) || false;
636
+ const pending = [];
637
+ /**
638
+ * The side a wire should bend through to reach another frame without crossing
639
+ * one.
640
+ *
641
+ * Two sessions in the **same column** — siblings, or any two frames at one
642
+ * depth — cannot be reached left-to-right: a straight line from the sender's
643
+ * card to the recipient's facing edge runs back across the recipient's own box,
644
+ * which reads as "this wire belongs to that frame". Such a wire bends down the
645
+ * gutter beside the column instead: out of the card, along the gutter, into the
646
+ * frame's edge on that same side. The gutter is on the right except in the last
647
+ * column, where that would run along the outside of the drawing.
648
+ */
649
+ const gutterSide = (column) => column < columns.length - 1 ? "right" : "left";
650
+ const gutterX = (column, side) => {
651
+ const box = columns[column]?.bounds ?? {
652
+ x: 0,
653
+ y: 0,
654
+ width: 0,
655
+ height: 0
656
+ };
657
+ return side === "right" ? box.x + box.width + opts.columnGap / 2 : box.x - opts.columnGap / 2;
658
+ };
659
+ const frontByReport = new Map(reports.map((front) => [front.report, front]));
660
+ for (const [cardIndex, card] of cards.entries()) {
661
+ const front = frontByReport.get(card.report);
662
+ if (front === void 0) continue;
663
+ const ownFrame = card.frame;
664
+ const ownColumn = card.column;
665
+ for (const [kind, ids] of [["to", front.to], ["cc", front.cc]]) for (const id of new Set(ids)) {
666
+ const target = frameIndexById(id);
667
+ if (target < 0 || target === ownFrame) continue;
668
+ const targetColumn = frames[target]?.column ?? 0;
669
+ const sameColumn = targetColumn === ownColumn;
670
+ const side = sameColumn ? gutterSide(ownColumn) : targetColumn > ownColumn ? "right" : "left";
671
+ pending.push({
672
+ report: front.report,
673
+ kind,
674
+ from: cardEnd(cardIndex, side),
675
+ to: frameEnd(target, sameColumn ? side : side === "right" ? "left" : "right"),
676
+ external: externalBetween(ownFrame, target),
677
+ ...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
678
+ });
679
+ }
680
+ for (const id of new Set(front.authors)) {
681
+ if (id === front.from) continue;
682
+ const source = frameIndexById(id);
683
+ if (source < 0 || source === ownFrame) continue;
684
+ const sourceColumn = frames[source]?.column ?? 0;
685
+ const sameColumn = sourceColumn === ownColumn;
686
+ const side = sameColumn ? gutterSide(ownColumn) : sourceColumn < ownColumn ? "right" : "left";
687
+ pending.push({
688
+ report: front.report,
689
+ kind: "author",
690
+ from: frameEnd(source, side),
691
+ to: cardEnd(cardIndex, sameColumn ? side : side === "right" ? "left" : "right"),
692
+ external: externalBetween(source, ownFrame),
693
+ ...sameColumn ? { viaX: gutterX(ownColumn, side) } : {}
694
+ });
695
+ }
696
+ const parentIndex = front.parent === void 0 || front.parent === front.report ? void 0 : cardIndexOf.get(front.parent);
697
+ const parentCard = parentIndex === void 0 ? void 0 : cards[parentIndex];
698
+ if (parentIndex !== void 0 && parentCard !== void 0) {
699
+ const from = cardEnd(parentIndex, "bottom");
700
+ const to = cardEnd(cardIndex, "top");
701
+ const column = parentCard.column;
702
+ const target = card.column;
703
+ const side = target > column ? "right" : target < column ? "left" : gutterSide(column);
704
+ pending.push({
705
+ report: front.report,
706
+ kind: "thread",
707
+ from,
708
+ to,
709
+ external: externalBetween(parentCard.frame, ownFrame),
710
+ viaX: gutterX(column, side)
711
+ });
712
+ }
713
+ }
714
+ const groups = /* @__PURE__ */ new Map();
715
+ for (const edge of pending) for (const end of [edge.from, edge.to]) {
716
+ if (end.kind !== "frame") continue;
717
+ const other = end === edge.from ? edge.to : edge.from;
718
+ const key = `${end.index}|${end.side}`;
719
+ const list = groups.get(key) ?? [];
720
+ list.push({
721
+ end,
722
+ order: other.y,
723
+ tie: `${edge.report}|${edge.kind}`
724
+ });
725
+ groups.set(key, list);
726
+ }
727
+ for (const list of groups.values()) {
728
+ list.sort((a, b) => a.order - b.order || (a.tie < b.tie ? -1 : a.tie > b.tie ? 1 : 0));
729
+ const box = fixed[list[0]?.end.index ?? 0] ?? {
730
+ x: 0,
731
+ y: 0,
732
+ width: 0,
733
+ height: 0
734
+ };
735
+ const top = box.y + opts.frameHeaderHeight + opts.framePaddingTop;
736
+ const span = box.y + box.height - opts.framePaddingBottom - top;
737
+ list.forEach((entry, index) => {
738
+ entry.end.y = span > 0 ? top + (index + 1) * span / (list.length + 1) : box.y + box.height / 2;
739
+ });
740
+ }
741
+ const edges = pending.map((edge) => {
742
+ const xs = [
743
+ edge.from.x,
744
+ edge.to.x,
745
+ ...edge.viaX === void 0 ? [] : [edge.viaX]
746
+ ];
747
+ const ys = [edge.from.y, edge.to.y];
748
+ const minX = Math.min(...xs);
749
+ const minY = Math.min(...ys);
750
+ return {
751
+ report: edge.report,
752
+ kind: edge.kind,
753
+ from: { ...edge.from },
754
+ to: { ...edge.to },
755
+ external: edge.external,
756
+ ...edge.viaX === void 0 ? {} : { viaX: edge.viaX },
757
+ bounds: {
758
+ x: minX,
759
+ y: minY,
760
+ width: Math.max(...xs) - minX,
761
+ height: Math.max(...ys) - minY
762
+ }
763
+ };
764
+ });
765
+ const contentWidth = columnCount === 0 ? 0 : opts.margin * 2 + columnCount * frameWidth + (columnCount - 1) * opts.columnGap;
766
+ const widest = heights.reduce((max, height) => Math.max(max, height), 0);
767
+ return {
768
+ lod: opts.lod,
769
+ columns,
770
+ frames: layoutFrames,
771
+ cards,
772
+ edges,
773
+ frameWidth,
774
+ cardWidth: size.width,
775
+ cardHeight: size.height,
776
+ width: columnCount === 0 ? 0 : contentWidth,
777
+ height: columnCount === 0 ? 0 : opts.margin * 2 + widest
778
+ };
779
+ }
780
+ //#endregion
781
+ //#region src/client/cardgraph-painter.ts
782
+ /** A guaranteed-visible theme, for a context with no tokens at all. */
783
+ const FALLBACK_THEME = {
784
+ canvas: "#ffffff",
785
+ frame: "#f6f7f9",
786
+ frameBar: "#eef0f3",
787
+ surface: "#ffffff",
788
+ border: "#d8dbe0",
789
+ frameBorder: "#c3c7ce",
790
+ text: "#1a1c1f",
791
+ secondary: "#4a4f57",
792
+ caption: "#70757d",
793
+ tertiary: "#9aa0a8",
794
+ accent: "#2f6fed",
795
+ warning: "#c77700",
796
+ success: "#1f9254",
797
+ error: "#d33a2c",
798
+ business: "#2f6fed",
799
+ fonts: {
800
+ title: {
801
+ size: 13,
802
+ weight: 500,
803
+ lineHeight: 18,
804
+ family: "system-ui, sans-serif"
805
+ },
806
+ id: {
807
+ size: 12,
808
+ weight: 600,
809
+ lineHeight: 16,
810
+ family: "system-ui, sans-serif"
811
+ },
812
+ body: {
813
+ size: 11,
814
+ weight: 400,
815
+ lineHeight: 15,
816
+ family: "system-ui, sans-serif"
817
+ },
818
+ meta: {
819
+ size: 11,
820
+ weight: 500,
821
+ lineHeight: 15,
822
+ family: "system-ui, sans-serif"
823
+ }
824
+ }
825
+ };
826
+ /** Token name → the field it fills. Keeping it as data means one reader, not twenty. */
827
+ const TOKENS = [
828
+ [
829
+ "canvas",
830
+ "--dsw-alias-bg-base",
831
+ FALLBACK_THEME.canvas
832
+ ],
833
+ [
834
+ "frame",
835
+ "--dsw-alias-bg-layer-1",
836
+ FALLBACK_THEME.frame
837
+ ],
838
+ [
839
+ "frameBar",
840
+ "--dsw-alias-bg-layer-2",
841
+ FALLBACK_THEME.frameBar
842
+ ],
843
+ [
844
+ "surface",
845
+ "--dsw-alias-bg-layer-3",
846
+ FALLBACK_THEME.surface
847
+ ],
848
+ [
849
+ "border",
850
+ "--dsw-alias-border-l1",
851
+ FALLBACK_THEME.border
852
+ ],
853
+ [
854
+ "frameBorder",
855
+ "--dsw-alias-border-l2",
856
+ FALLBACK_THEME.frameBorder
857
+ ],
858
+ [
859
+ "text",
860
+ "--dsw-alias-label-primary",
861
+ FALLBACK_THEME.text
862
+ ],
863
+ [
864
+ "secondary",
865
+ "--dsw-alias-label-secondary",
866
+ FALLBACK_THEME.secondary
867
+ ],
868
+ [
869
+ "caption",
870
+ "--dsw-alias-label-caption",
871
+ FALLBACK_THEME.caption
872
+ ],
873
+ [
874
+ "tertiary",
875
+ "--dsw-alias-label-tertiary",
876
+ FALLBACK_THEME.tertiary
877
+ ],
878
+ [
879
+ "accent",
880
+ "--dsw-alias-brand-primary",
881
+ FALLBACK_THEME.accent
882
+ ],
883
+ [
884
+ "warning",
885
+ "--dsw-alias-state-warn-primary",
886
+ FALLBACK_THEME.warning
887
+ ],
888
+ [
889
+ "success",
890
+ "--dsw-alias-state-success-primary",
891
+ FALLBACK_THEME.success
892
+ ],
893
+ [
894
+ "error",
895
+ "--dsw-alias-state-error-primary",
896
+ FALLBACK_THEME.error
897
+ ],
898
+ [
899
+ "business",
900
+ "--dsw-alias-state-business-primary",
901
+ FALLBACK_THEME.business
902
+ ]
903
+ ];
904
+ /** Font token → the field it fills. Each token carries its own size, weight and family. */
905
+ const FONT_TOKENS = [
906
+ [
907
+ "title",
908
+ "--dsw-font-xs-13",
909
+ FALLBACK_THEME.fonts.title
910
+ ],
911
+ [
912
+ "id",
913
+ "--dsw-font-xxs-strong-12",
914
+ FALLBACK_THEME.fonts.id
915
+ ],
916
+ [
917
+ "body",
918
+ "--dsw-font-xxxs-11",
919
+ FALLBACK_THEME.fonts.body
920
+ ],
921
+ [
922
+ "meta",
923
+ "--dsw-font-xxxs-strong-11",
924
+ FALLBACK_THEME.fonts.meta
925
+ ]
926
+ ];
927
+ /** The one token a theme change always moves, so a repaint can detect it cheaply. */
928
+ const CANARY = "--dsw-alias-bg-base";
929
+ /** Assemble a canvas font string. */
930
+ function fontString(font) {
931
+ return `${font.weight} ${font.size}px ${font.family}`;
932
+ }
933
+ /** Read one font token, taking its size/weight/line-height/family parts. */
934
+ function readFont(read, token, fallback) {
935
+ const size = Number.parseFloat(read(`${token}-font-size`));
936
+ const weight = Number.parseFloat(read(`${token}-font-weight`));
937
+ const lineHeight = Number.parseFloat(read(`${token}-line-height`));
938
+ const family = read(`${token}-font-family`);
939
+ return {
940
+ size: Number.isFinite(size) && size > 0 ? size : fallback.size,
941
+ weight: Number.isFinite(weight) && weight > 0 ? weight : fallback.weight,
942
+ lineHeight: Number.isFinite(lineHeight) && lineHeight > 0 ? lineHeight : fallback.lineHeight,
943
+ family: family === "" ? fallback.family : family
944
+ };
945
+ }
946
+ /**
947
+ * Read the drawing's colours and fonts out of the live theme.
948
+ * @param read - a token reader, normally `getComputedStyle(root).getPropertyValue`.
949
+ * @returns the theme; every field falls back so a missing token cannot blank the drawing.
950
+ */
951
+ function readTheme(read) {
952
+ const pick = (token, fallback) => {
953
+ const value = read(token).trim();
954
+ return value === "" ? fallback : value;
955
+ };
956
+ const colours = {};
957
+ for (const [field, token, fallback] of TOKENS) colours[field] = pick(token, fallback);
958
+ const fonts = {};
959
+ for (const [field, token, fallback] of FONT_TOKENS) fonts[field] = readFont(read, token, fallback);
960
+ return {
961
+ ...FALLBACK_THEME,
962
+ ...colours,
963
+ fonts: {
964
+ ...FALLBACK_THEME.fonts,
965
+ ...fonts
966
+ }
967
+ };
968
+ }
969
+ /**
970
+ * Watch the theme through one token.
971
+ *
972
+ * `getComputedStyle(...).getPropertyValue` forces a style read, so doing twenty of
973
+ * them per repaint would be paying for the theme on every hover. The canary is the
974
+ * page background: if it did not move, nothing else did, and the cached theme is
975
+ * returned.
976
+ * @param root - the element carrying the tokens, normally `document.documentElement`.
977
+ * @param win - the window to read styles from.
978
+ * @returns the reader.
979
+ */
980
+ function createThemeReader(root, win) {
981
+ const style = win.getComputedStyle(root);
982
+ const read = (name) => style.getPropertyValue(name);
983
+ let canary;
984
+ let cached;
985
+ return { read() {
986
+ const current = read(CANARY);
987
+ if (cached === void 0 || current !== canary) {
988
+ canary = current;
989
+ cached = readTheme(read);
990
+ }
991
+ return cached;
992
+ } };
993
+ }
994
+ /**
995
+ * Shorten text until it fits, ending it with an ellipsis.
996
+ * @param text - the text to fit.
997
+ * @param maxWidth - the room available.
998
+ * @param measure - width of a candidate string.
999
+ * @returns text that fits, or `'…'` when not even one character does.
1000
+ */
1001
+ function elide(text, maxWidth, measure) {
1002
+ if (measure(text) <= maxWidth) return text;
1003
+ for (let take = text.length - 1; take > 0; take--) {
1004
+ const candidate = `${text.slice(0, take).trimEnd()}…`;
1005
+ if (measure(candidate) <= maxWidth) return candidate;
1006
+ }
1007
+ return "…";
1008
+ }
1009
+ /**
1010
+ * Wrap text to a width, eliding whatever does not fit in the last line.
1011
+ *
1012
+ * Character-by-character, with one concession to Latin text: when a line has to
1013
+ * break and there is a space far enough back, break at the space instead of mid
1014
+ * word. Chinese has no spaces, so a word-based rule would leave a long subject on
1015
+ * one line and elide most of it; this rule wraps it, and still avoids breaking
1016
+ * "report-ledger" in half when it can.
1017
+ * @param text - the text to lay out.
1018
+ * @param maxWidth - the room available.
1019
+ * @param measure - width of a candidate string.
1020
+ * @param maxLines - how many lines to produce.
1021
+ * @returns the lines, possibly empty.
1022
+ */
1023
+ function foldText(text, maxWidth, measure, maxLines = 1) {
1024
+ const clean = text.replace(/\s+/g, " ").trim();
1025
+ if (clean === "" || maxWidth <= 0 || maxLines < 1) return [];
1026
+ const lines = [];
1027
+ let rest = clean;
1028
+ while (rest !== "" && lines.length < maxLines) {
1029
+ if (lines.length === maxLines - 1) {
1030
+ lines.push(elide(rest, maxWidth, measure));
1031
+ break;
1032
+ }
1033
+ if (measure(rest) <= maxWidth) {
1034
+ lines.push(rest);
1035
+ break;
1036
+ }
1037
+ let cut = 0;
1038
+ for (let take = 1; take <= rest.length; take++) {
1039
+ if (measure(rest.slice(0, take)) > maxWidth) break;
1040
+ cut = take;
1041
+ }
1042
+ if (cut === 0) {
1043
+ lines.push(elide(rest, maxWidth, measure));
1044
+ break;
1045
+ }
1046
+ const space = rest.lastIndexOf(" ", cut);
1047
+ if (space > cut / 3) cut = space;
1048
+ lines.push(rest.slice(0, cut).trimEnd());
1049
+ rest = rest.slice(cut).trimStart();
1050
+ }
1051
+ return lines;
1052
+ }
1053
+ /** Corner radius of a card. */
1054
+ const CARD_RADIUS = 5;
1055
+ /** Corner radius of a frame. */
1056
+ const FRAME_RADIUS = 7;
1057
+ /** Inset from a card's edge to its text. */
1058
+ const CARD_PADDING = 9;
1059
+ /** Width of the status stripe down a card's left edge. */
1060
+ const STRIPE = 3;
1061
+ /** Arrowhead length and half-width. */
1062
+ const ARROW_LENGTH = 7;
1063
+ const ARROW_HALF = 3.5;
1064
+ /**
1065
+ * How far the scrim pushes everything but the focused path back.
1066
+ *
1067
+ * **Measured, then corrected.** The first version reused the swimlane's dim value
1068
+ * (0.18 left showing) and a screenshot of the real tab showed why that was wrong
1069
+ * there and wrong here: at 0.18 the frames, their titles and every other card
1070
+ * become unreadable, so tracing one path costs the reader the context the drawing
1071
+ * exists to provide. The swimlane had already learned the same thing from the other
1072
+ * side — *the relations recede, the participants stay* — and a canvas scrim is
1073
+ * blunter than a per-shape dim, so it has to be gentler: 0.45 showing keeps every
1074
+ * frame and card legible while the focused path, drawn back on top at full
1075
+ * strength, still reads as the thing being pointed at.
1076
+ */
1077
+ const SCRIM_ALPHA = .55;
1078
+ /** Subject lines a full card has room for. */
1079
+ const SUBJECT_LINES = 2;
1080
+ /** The colour a status is drawn in, matching the tab's tones. */
1081
+ function statusColour(status, theme) {
1082
+ if (status === "open") return theme.warning;
1083
+ if (status === "acked") return theme.success;
1084
+ return theme.tertiary;
1085
+ }
1086
+ /**
1087
+ * How each kind of wire is drawn: the token its colour comes from, its dash
1088
+ * pattern, and its weight.
1089
+ *
1090
+ * Exported because the wires are canvas and the legend is DOM — the only way the
1091
+ * two cannot drift is for the legend to read this table instead of restating it in
1092
+ * CSS. The colour is a **token name**, not a value, so it resolves through the
1093
+ * theme like everything else.
1094
+ */
1095
+ const WIRE_TOKENS = {
1096
+ to: {
1097
+ colour: "--dsw-alias-label-secondary",
1098
+ dash: [],
1099
+ width: 1.4
1100
+ },
1101
+ cc: {
1102
+ colour: "--dsw-alias-label-caption",
1103
+ dash: [2, 3],
1104
+ width: 1
1105
+ },
1106
+ author: {
1107
+ colour: "--dsw-alias-label-caption",
1108
+ dash: [1, 3],
1109
+ width: 1
1110
+ },
1111
+ thread: {
1112
+ colour: "--dsw-alias-state-business-primary",
1113
+ dash: [],
1114
+ width: 1.4
1115
+ }
1116
+ };
1117
+ /** Reverse lookup: which theme field a token fills. */
1118
+ const FIELD_OF_TOKEN = new Map(TOKENS.map(([field, token]) => [token, field]));
1119
+ /** How one kind of wire is drawn, resolved against a theme. */
1120
+ function wireStyle(kind, theme) {
1121
+ const spec = WIRE_TOKENS[kind];
1122
+ const field = FIELD_OF_TOKEN.get(spec.colour);
1123
+ return {
1124
+ stroke: field === void 0 ? theme.secondary : theme[field],
1125
+ dash: spec.dash,
1126
+ width: spec.width
1127
+ };
1128
+ }
1129
+ /**
1130
+ * The polyline a wire is drawn as.
1131
+ *
1132
+ * A wire with a gutter bends through it in three segments — out, along, in — which
1133
+ * is what keeps a same-column wire from crossing the frame it lands on and a
1134
+ * thread from crossing the cards between its ends.
1135
+ * @param edge - the wire.
1136
+ * @returns between two and four points, first to last.
1137
+ */
1138
+ function wirePoints(edge) {
1139
+ const from = {
1140
+ x: edge.from.x,
1141
+ y: edge.from.y
1142
+ };
1143
+ const to = {
1144
+ x: edge.to.x,
1145
+ y: edge.to.y
1146
+ };
1147
+ if (edge.viaX === void 0) return [from, to];
1148
+ return [
1149
+ from,
1150
+ {
1151
+ x: edge.viaX,
1152
+ y: from.y
1153
+ },
1154
+ {
1155
+ x: edge.viaX,
1156
+ y: to.y
1157
+ },
1158
+ to
1159
+ ];
1160
+ }
1161
+ /** Trace a rounded rectangle into the current path. */
1162
+ function pathRoundRect(ctx, rect, radius) {
1163
+ const r = Math.max(0, Math.min(radius, rect.width / 2, rect.height / 2));
1164
+ ctx.beginPath();
1165
+ ctx.moveTo(rect.x + r, rect.y);
1166
+ ctx.lineTo(rect.x + rect.width - r, rect.y);
1167
+ ctx.arcTo(rect.x + rect.width, rect.y, rect.x + rect.width, rect.y + r, r);
1168
+ ctx.lineTo(rect.x + rect.width, rect.y + rect.height - r);
1169
+ ctx.arcTo(rect.x + rect.width, rect.y + rect.height, rect.x + rect.width - r, rect.y + rect.height, r);
1170
+ ctx.lineTo(rect.x + r, rect.y + rect.height);
1171
+ ctx.arcTo(rect.x, rect.y + rect.height, rect.x, rect.y + rect.height - r, r);
1172
+ ctx.lineTo(rect.x, rect.y + r);
1173
+ ctx.arcTo(rect.x, rect.y, rect.x + r, rect.y, r);
1174
+ ctx.closePath();
1175
+ }
1176
+ /**
1177
+ * Build a painter that draws into a canvas.
1178
+ *
1179
+ * One transform carries pan and zoom; everything after that is drawn in the
1180
+ * layout's own coordinates. Coordinates are deliberately **not** snapped to
1181
+ * pixels: draw.io rounds after its transform because its canvas is 1:1 screen
1182
+ * space, while ours is scaled, so snapping would have to happen per point in
1183
+ * screen space. Antialiasing is the honest default until a measurement says the
1184
+ * blur costs readability.
1185
+ * @param canvas - the canvas to draw into.
1186
+ * @returns the painter.
1187
+ */
1188
+ function createCanvasPainter(canvas) {
1189
+ const ctx = canvas.getContext("2d");
1190
+ if (ctx === null) throw new Error("card canvas: no 2d context");
1191
+ let width = 0;
1192
+ let height = 0;
1193
+ let dpr = 1;
1194
+ /** Measured text widths, keyed by font and text: measuring is a layout call. */
1195
+ const widths = /* @__PURE__ */ new Map();
1196
+ const measure = (text) => {
1197
+ const key = `${ctx.font}\u0000${text}`;
1198
+ const hit = widths.get(key);
1199
+ if (hit !== void 0) return hit;
1200
+ const value = ctx.measureText(text).width;
1201
+ widths.set(key, value);
1202
+ return value;
1203
+ };
1204
+ const line = (x1, y1, x2, y2, colour, dash, alpha) => {
1205
+ ctx.globalAlpha = alpha;
1206
+ ctx.strokeStyle = colour;
1207
+ ctx.setLineDash(dash);
1208
+ ctx.beginPath();
1209
+ ctx.moveTo(x1, y1);
1210
+ ctx.lineTo(x2, y2);
1211
+ ctx.stroke();
1212
+ };
1213
+ /** The frame's outline alone, for the interaction layer: the box is already drawn. */
1214
+ const drawFrameOutline = (frame, scene) => {
1215
+ ctx.globalAlpha = 1;
1216
+ pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
1217
+ ctx.strokeStyle = scene.theme.accent;
1218
+ ctx.lineWidth = 1.6;
1219
+ ctx.stroke();
1220
+ };
1221
+ const drawFrame = (frame, scene) => {
1222
+ const { theme } = scene;
1223
+ ctx.globalAlpha = 1;
1224
+ pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
1225
+ ctx.fillStyle = theme.frame;
1226
+ ctx.fill();
1227
+ ctx.strokeStyle = theme.frameBorder;
1228
+ ctx.lineWidth = 1;
1229
+ ctx.stroke();
1230
+ const bar = {
1231
+ x: frame.bounds.x,
1232
+ y: frame.bounds.y,
1233
+ width: frame.bounds.width,
1234
+ height: 26
1235
+ };
1236
+ ctx.save();
1237
+ pathRoundRect(ctx, frame.bounds, FRAME_RADIUS);
1238
+ ctx.clip();
1239
+ ctx.globalAlpha = 1;
1240
+ ctx.fillStyle = theme.frameBar;
1241
+ ctx.fillRect(bar.x, bar.y, bar.width, bar.height);
1242
+ ctx.restore();
1243
+ line(bar.x, bar.y + bar.height, bar.x + bar.width, bar.y + bar.height, theme.frameBorder, [], 1);
1244
+ ctx.font = fontString(theme.fonts.title);
1245
+ ctx.textAlign = "left";
1246
+ ctx.textBaseline = "middle";
1247
+ ctx.fillStyle = theme.secondary;
1248
+ const label = frame.external ? scene.labels.external : frame.title ?? frame.shortId;
1249
+ ctx.fillText(elide(label, frame.bounds.width - 24 - 10, measure), bar.x + 10, bar.y + bar.height / 2 + .5);
1250
+ const cx = bar.x + bar.width - 13;
1251
+ const cy = bar.y + bar.height / 2;
1252
+ ctx.beginPath();
1253
+ ctx.arc(cx, cy, 3.5, 0, Math.PI * 2);
1254
+ if (frame.live) {
1255
+ ctx.fillStyle = theme.success;
1256
+ ctx.fill();
1257
+ } else {
1258
+ ctx.strokeStyle = theme.tertiary;
1259
+ ctx.lineWidth = 1;
1260
+ ctx.stroke();
1261
+ }
1262
+ if (frame.cards === 0) {
1263
+ ctx.font = fontString(theme.fonts.body);
1264
+ ctx.fillStyle = theme.tertiary;
1265
+ ctx.fillText(scene.labels.empty, frame.bounds.x + CARD_PADDING, bar.y + bar.height + 18);
1266
+ }
1267
+ };
1268
+ const drawWire = (edge, scene, alpha) => {
1269
+ const { theme } = scene;
1270
+ const style = wireStyle(edge.kind, theme);
1271
+ const points = wirePoints(edge);
1272
+ ctx.globalAlpha = alpha;
1273
+ ctx.strokeStyle = style.stroke;
1274
+ ctx.lineWidth = style.width;
1275
+ ctx.setLineDash(style.dash);
1276
+ ctx.beginPath();
1277
+ points.forEach((point, index) => {
1278
+ if (index === 0) ctx.moveTo(point.x, point.y);
1279
+ else ctx.lineTo(point.x, point.y);
1280
+ });
1281
+ ctx.stroke();
1282
+ const last = points[points.length - 2];
1283
+ const end = points[points.length - 1];
1284
+ if (last === void 0 || end === void 0) return;
1285
+ const angle = Math.atan2(end.y - last.y, end.x - last.x);
1286
+ const back = {
1287
+ x: end.x - Math.cos(angle) * ARROW_LENGTH,
1288
+ y: end.y - Math.sin(angle) * ARROW_LENGTH
1289
+ };
1290
+ const side = {
1291
+ x: -Math.sin(angle) * ARROW_HALF,
1292
+ y: Math.cos(angle) * ARROW_HALF
1293
+ };
1294
+ ctx.setLineDash([]);
1295
+ ctx.fillStyle = style.stroke;
1296
+ ctx.beginPath();
1297
+ ctx.moveTo(end.x, end.y);
1298
+ ctx.lineTo(back.x + side.x, back.y + side.y);
1299
+ ctx.lineTo(back.x - side.x, back.y - side.y);
1300
+ ctx.closePath();
1301
+ ctx.fill();
1302
+ };
1303
+ const drawCard = (card, scene, accent) => {
1304
+ const { theme } = scene;
1305
+ const box = card.bounds;
1306
+ ctx.globalAlpha = 1;
1307
+ pathRoundRect(ctx, box, CARD_RADIUS);
1308
+ ctx.fillStyle = card.lod === "chip" ? statusColour(card.status, theme) : theme.surface;
1309
+ ctx.fill();
1310
+ if (card.lod === "chip") return;
1311
+ ctx.strokeStyle = accent ? theme.accent : theme.border;
1312
+ ctx.lineWidth = accent ? 1.6 : 1;
1313
+ ctx.stroke();
1314
+ ctx.save();
1315
+ pathRoundRect(ctx, box, CARD_RADIUS);
1316
+ ctx.clip();
1317
+ ctx.fillStyle = statusColour(card.status, theme);
1318
+ ctx.fillRect(box.x, box.y, STRIPE, box.height);
1319
+ ctx.restore();
1320
+ const textLeft = box.x + CARD_PADDING + STRIPE;
1321
+ const textWidth = box.width - CARD_PADDING * 2 - STRIPE;
1322
+ ctx.textAlign = "left";
1323
+ ctx.textBaseline = "top";
1324
+ ctx.font = fontString(theme.fonts.id);
1325
+ ctx.fillStyle = accent ? theme.accent : theme.secondary;
1326
+ if (card.lod === "compact") {
1327
+ const id = card.report;
1328
+ const idWidth = measure(id);
1329
+ const rest = foldText(`· ${card.subject}`, textWidth - idWidth - 4, measure, 1)[0] ?? "";
1330
+ ctx.fillText(id, textLeft, box.y + box.height / 2 - theme.fonts.id.size / 2 - 1);
1331
+ ctx.font = fontString(theme.fonts.body);
1332
+ ctx.fillStyle = theme.caption;
1333
+ ctx.fillText(rest, textLeft + idWidth + 4, box.y + box.height / 2 - theme.fonts.body.size / 2 - 1);
1334
+ return;
1335
+ }
1336
+ ctx.fillText(card.report, textLeft, box.y + 8);
1337
+ ctx.font = fontString(theme.fonts.body);
1338
+ ctx.fillStyle = theme.text;
1339
+ foldText(card.subject, textWidth, measure, SUBJECT_LINES).forEach((text, index) => {
1340
+ ctx.fillText(text, textLeft, box.y + 28 + index * theme.fonts.body.lineHeight);
1341
+ });
1342
+ ctx.font = fontString(theme.fonts.meta);
1343
+ const baseline = box.y + box.height - theme.fonts.meta.lineHeight - 6;
1344
+ ctx.fillStyle = statusColour(card.status, theme);
1345
+ const status = scene.labels.status[card.status] ?? card.status;
1346
+ ctx.fillText(status, textLeft, baseline);
1347
+ if (card.task !== void 0) {
1348
+ const chipLeft = textLeft + measure(status) + 8;
1349
+ const chipText = foldText(card.task, box.x + box.width - CARD_PADDING - chipLeft - 10, measure, 1)[0] ?? "";
1350
+ if (chipText !== "") {
1351
+ ctx.fillStyle = theme.frameBar;
1352
+ const chipWidth = measure(chipText) + 10;
1353
+ ctx.fillRect(chipLeft, baseline - 2, chipWidth, theme.fonts.meta.lineHeight + 2);
1354
+ ctx.fillStyle = theme.caption;
1355
+ ctx.fillText(chipText, chipLeft + 5, baseline);
1356
+ }
1357
+ }
1358
+ };
1359
+ return {
1360
+ resize(w, h, ratio) {
1361
+ width = Math.max(0, w);
1362
+ height = Math.max(0, h);
1363
+ dpr = Number.isFinite(ratio) && ratio > 0 ? ratio : 1;
1364
+ canvas.width = Math.max(1, Math.round(width * dpr));
1365
+ canvas.height = Math.max(1, Math.round(height * dpr));
1366
+ canvas.style.width = `${width}px`;
1367
+ canvas.style.height = `${height}px`;
1368
+ },
1369
+ reset() {
1370
+ widths.clear();
1371
+ },
1372
+ paint(scene) {
1373
+ const { layout, theme, viewport } = scene;
1374
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1375
+ ctx.globalAlpha = 1;
1376
+ ctx.setLineDash([]);
1377
+ ctx.clearRect(0, 0, width, height);
1378
+ ctx.fillStyle = theme.canvas;
1379
+ ctx.fillRect(0, 0, width, height);
1380
+ const world = worldViewport(viewport, scene.overscan ?? 0);
1381
+ const visible = (rect) => intersects(rect, world);
1382
+ ctx.save();
1383
+ ctx.translate(viewport.offsetX, viewport.offsetY);
1384
+ ctx.scale(viewport.scale, viewport.scale);
1385
+ for (const frame of layout.frames) if (visible(frame.bounds)) drawFrame(frame, scene);
1386
+ for (const edge of layout.edges) if (visible(edge.bounds)) drawWire(edge, scene, 1);
1387
+ for (const card of layout.cards) {
1388
+ if (!visible(card.bounds)) continue;
1389
+ drawCard(card, scene, card.report === scene.focus?.selected);
1390
+ }
1391
+ ctx.globalAlpha = 1;
1392
+ ctx.restore();
1393
+ },
1394
+ paintPath(scene) {
1395
+ const { layout, theme, viewport } = scene;
1396
+ const report = scene.focus?.report ?? scene.focus?.selected;
1397
+ const frameIndex = scene.focus?.frame;
1398
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
1399
+ ctx.globalAlpha = 1;
1400
+ ctx.setLineDash([]);
1401
+ ctx.clearRect(0, 0, width, height);
1402
+ if (report === void 0 && frameIndex === void 0) return;
1403
+ ctx.save();
1404
+ ctx.translate(viewport.offsetX, viewport.offsetY);
1405
+ ctx.scale(viewport.scale, viewport.scale);
1406
+ if (report === void 0) {
1407
+ const frame = layout.frames[frameIndex];
1408
+ if (frame !== void 0) drawFrameOutline(frame, scene);
1409
+ ctx.restore();
1410
+ return;
1411
+ }
1412
+ const world = worldViewport(viewport, 0);
1413
+ ctx.globalAlpha = SCRIM_ALPHA;
1414
+ ctx.fillStyle = theme.canvas;
1415
+ ctx.fillRect(world.x, world.y, world.width, world.height);
1416
+ ctx.globalAlpha = 1;
1417
+ const edges = layout.edges.filter((edge) => edge.report === report);
1418
+ const touched = /* @__PURE__ */ new Set();
1419
+ for (const edge of edges) {
1420
+ if (edge.from.kind === "frame") touched.add(edge.from.index);
1421
+ if (edge.to.kind === "frame") touched.add(edge.to.index);
1422
+ }
1423
+ for (const frame of layout.frames) if (touched.has(frame.index)) drawFrameOutline(frame, scene);
1424
+ for (const edge of edges) if (intersects(edge.bounds, world)) drawWire(edge, scene, 1);
1425
+ for (const card of layout.cards) if (card.report === report) drawCard(card, scene, true);
1426
+ ctx.restore();
1427
+ }
1428
+ };
1429
+ }
1430
+ //#endregion
1431
+ //#region src/client/locales.ts
1432
+ /** Chinese dictionary — the source of truth for the key set. */
1433
+ const zh = {
1434
+ "view.tab": "汇报",
1435
+ "view.loading": "正在读取账本…",
1436
+ "view.summary": "{sessions} 个会话 · {reports} 份汇报",
1437
+ "view.empty": "这个会话树下还没有汇报。代理用 report_author 开一份汇报后,这里会出现它的缩略卡。",
1438
+ "view.refresh": "刷新",
1439
+ "view.retry": "重试",
1440
+ "view.error": "读取失败:{code}",
1441
+ "view.root": "本会话",
1442
+ "view.live": "在线",
1443
+ "view.delegated": "子代理",
1444
+ "view.detail.loading": "正在读取传递路径…",
1445
+ "view.detail.route": "传递路径",
1446
+ "view.detail.routeEmpty": "(还没有记录跳)",
1447
+ "view.detail.pending": "仍在等待送达:{targets}",
1448
+ "view.detail.body": "正文",
1449
+ "view.detail.path": "账本文件",
1450
+ "view.copyPath": "复制账本路径",
1451
+ "row.hops": "{count} 跳",
1452
+ "row.authors": "{count} 位共写",
1453
+ "row.filterByTask": "只看任务「{task}」",
1454
+ "row.filterByWorktree": "只看检出「{worktree}」",
1455
+ "row.leaseHeld": "工作区:正在被这份汇报占用",
1456
+ "row.leaseReleased": "工作区:占用已结束,检出仍留在磁盘上",
1457
+ "row.leaseReclaimed": "工作区:已被账本回收清除",
1458
+ "row.leaseNone": "工作区:绑定已记录,但从未被占用",
1459
+ "row.expand": "展开 {report}",
1460
+ "row.collapse": "收起 {report}",
1461
+ "row.last": "最后",
1462
+ "hop.authored": "发起",
1463
+ "hop.contributed": "共写",
1464
+ "hop.sent": "主送",
1465
+ "hop.delivered": "送达",
1466
+ "hop.cc": "抄送",
1467
+ "hop.forwarded": "转呈",
1468
+ "hop.copied": "副本",
1469
+ "hop.read": "阅读",
1470
+ "hop.acked": "回执",
1471
+ "hop.leased": "认领工作区",
1472
+ "hop.released": "释放工作区",
1473
+ "hop.reclaimed": "回收工作区",
1474
+ "hop.amended": "修正",
1475
+ "hop.closed": "结案",
1476
+ "hop.reopened": "重开",
1477
+ "filter.all": "全部",
1478
+ "filter.open": "进行中",
1479
+ "filter.acked": "已回执",
1480
+ "filter.closed": "已结案",
1481
+ "filter.search": "搜索主题、编号、会话或收发…",
1482
+ "filter.clear": "清除",
1483
+ "filter.showing": "显示 {visible}/{total} 份汇报",
1484
+ "filter.none": "没有符合当前筛选的汇报。",
1485
+ "filter.hint": "状态筛选只作用于汇报;搜索会同时匹配会话标题与编号,以及任务标签和检出名。",
1486
+ "basis.created": "按创建",
1487
+ "basis.updated": "按最近活动",
1488
+ "basis.hint": "汇报按创建时间还是按最后一次活动排入时间线;会话始终按创建时间。",
1489
+ "detail.thread": "线程",
1490
+ "detail.parent": "上溯",
1491
+ "detail.children": "下递",
1492
+ "detail.openLinked": "打开 {report}",
1493
+ "detail.outside": "{report} 不在当前会话树的列表里(它可能属于另一条线);正文与路径仍可读取。",
1494
+ "detail.filteredOut": "{report} 在当前筛选下被隐藏了(用「清除」恢复)。",
1495
+ "detail.backToList": "回到列表",
1496
+ "detail.bodyTruncated": "正文过长,此处只显示开头 {chars} 字;完整内容见账本文件。",
1497
+ "topology.title": "拓扑",
1498
+ "topology.summary": "{lanes} 条泳道 · {reports} 份汇报",
1499
+ "topology.show": "展开拓扑",
1500
+ "topology.hide": "收起拓扑",
1501
+ "topology.external": "树外",
1502
+ "topology.externalHint": "不属于本树的会话",
1503
+ "topology.openHint": "点击在列表中打开",
1504
+ "cardgraph.zoomIn": "放大",
1505
+ "cardgraph.zoomOut": "缩小",
1506
+ "cardgraph.zoom": "{percent}%",
1507
+ "cardgraph.fit": "适应窗口",
1508
+ "cardgraph.summary": "{frames} 个框 · {cards} 份汇报",
1509
+ "cardgraph.empty": "没有汇报",
1510
+ "body.copy": "复制",
1511
+ "body.copied": "已复制",
1512
+ "body.footnotes": "脚注"
1513
+ };
1514
+ /** English dictionary, checked against the Chinese key set. */
1515
+ const en = {
1516
+ "view.tab": "Reports",
1517
+ "view.loading": "Reading the ledger…",
1518
+ "view.summary": "{sessions} sessions · {reports} reports",
1519
+ "view.empty": "No reports in this session tree yet. Once an agent opens one with report_author, its digest card appears here.",
1520
+ "view.refresh": "Refresh",
1521
+ "view.retry": "Retry",
1522
+ "view.error": "Read failed: {code}",
1523
+ "view.root": "this session",
1524
+ "view.live": "live",
1525
+ "view.delegated": "subagent",
1526
+ "view.detail.loading": "Reading the transfer path…",
1527
+ "view.detail.route": "Transfer path",
1528
+ "view.detail.routeEmpty": "(no hops recorded yet)",
1529
+ "view.detail.pending": "Still owed to: {targets}",
1530
+ "view.detail.body": "Body",
1531
+ "view.detail.path": "Ledger file",
1532
+ "view.copyPath": "Copy the ledger path",
1533
+ "row.hops": "{count} hops",
1534
+ "row.authors": "{count} authors",
1535
+ "row.filterByTask": "Show only task \"{task}\"",
1536
+ "row.filterByWorktree": "Show only checkout \"{worktree}\"",
1537
+ "row.leaseHeld": "Checkout: currently claimed by this report",
1538
+ "row.leaseReleased": "Checkout: claim ended, the checkout is still on disk",
1539
+ "row.leaseReclaimed": "Checkout: cleared away by the ledger",
1540
+ "row.leaseNone": "Checkout: bound but never claimed",
1541
+ "row.expand": "Expand {report}",
1542
+ "row.collapse": "Collapse {report}",
1543
+ "row.last": "last",
1544
+ "hop.authored": "authored",
1545
+ "hop.contributed": "contributed",
1546
+ "hop.sent": "sent",
1547
+ "hop.delivered": "delivered",
1548
+ "hop.cc": "cc",
1549
+ "hop.forwarded": "forwarded",
1550
+ "hop.copied": "copied",
1551
+ "hop.read": "read",
1552
+ "hop.acked": "acked",
1553
+ "hop.leased": "leased",
1554
+ "hop.released": "released",
1555
+ "hop.reclaimed": "reclaimed",
1556
+ "hop.amended": "amended",
1557
+ "hop.closed": "closed",
1558
+ "hop.reopened": "reopened",
1559
+ "filter.all": "All",
1560
+ "filter.open": "Open",
1561
+ "filter.acked": "Acked",
1562
+ "filter.closed": "Closed",
1563
+ "filter.search": "Search subject, id, session, or people…",
1564
+ "filter.clear": "Clear",
1565
+ "filter.showing": "Showing {visible}/{total} reports",
1566
+ "filter.none": "No reports match the current filter.",
1567
+ "filter.hint": "The status filter narrows reports only; search matches every row, including session titles and ids, task labels and checkout names.",
1568
+ "basis.created": "By creation",
1569
+ "basis.updated": "By activity",
1570
+ "basis.hint": "Whether reports are placed at creation or at their latest activity; sessions always keep their creation time.",
1571
+ "detail.thread": "Thread",
1572
+ "detail.parent": "Answers",
1573
+ "detail.children": "Answered by",
1574
+ "detail.openLinked": "Open {report}",
1575
+ "detail.outside": "{report} is not in this session tree's list (it may belong to another line); its body and path are still readable.",
1576
+ "detail.filteredOut": "{report} is hidden by the current filter (use Clear to restore it).",
1577
+ "detail.backToList": "Back to the list",
1578
+ "detail.bodyTruncated": "Body is long, so only the first {chars} characters are shown here; the ledger file has the whole thing.",
1579
+ "topology.title": "Topology",
1580
+ "topology.summary": "{lanes} lanes · {reports} reports",
1581
+ "topology.show": "Show topology",
1582
+ "topology.hide": "Hide topology",
1583
+ "topology.external": "outside",
1584
+ "topology.externalHint": "Sessions outside this tree",
1585
+ "topology.openHint": "Click to open it in the list",
1586
+ "cardgraph.zoomIn": "Zoom in",
1587
+ "cardgraph.zoomOut": "Zoom out",
1588
+ "cardgraph.zoom": "{percent}%",
1589
+ "cardgraph.fit": "Fit",
1590
+ "cardgraph.summary": "{frames} frames · {cards} reports",
1591
+ "cardgraph.empty": "no reports",
1592
+ "body.copy": "Copy",
1593
+ "body.copied": "Copied",
1594
+ "body.footnotes": "Footnotes"
1595
+ };
1596
+ /**
1597
+ * Lifecycle state → dictionary key.
1598
+ *
1599
+ * Deliberately the SAME keys the toolbar's filter chips use. A card's status
1600
+ * chip and the filter that selects it name one state, so giving them two
1601
+ * vocabularies (raw `open` on the card, 进行中 in the toolbar) made the reader
1602
+ * build the mapping themselves — and the mapping was the bug, not the words.
1603
+ */
1604
+ const STATUS_LABEL = {
1605
+ open: "filter.open",
1606
+ acked: "filter.acked",
1607
+ closed: "filter.closed"
1608
+ };
1609
+ /**
1610
+ * Lifecycle state → the shell's chip tone.
1611
+ *
1612
+ * Colour-coded status is the point of the chip, and the shell's palette already
1613
+ * has the three tones this needs — so the states ride `Tag` instead of a
1614
+ * hand-mixed background. `open` reads as the one that wants attention. The tone
1615
+ * names are the ones the shell's CSS actually defines (`[data-tone=…]`), which
1616
+ * is a longer list than the shipped views happen to use.
1617
+ */
1618
+ const STATUS_TONE = {
1619
+ open: "warning",
1620
+ acked: "success",
1621
+ closed: "neutral"
1622
+ };
1623
+ /**
1624
+ * Edge kind → the word the drawing calls it.
1625
+ *
1626
+ * The topology borrows the transfer path's own vocabulary rather than inventing
1627
+ * a second one: an arrow in the graph and a row in the panel name the same
1628
+ * relation, so they say the same thing.
1629
+ */
1630
+ const EDGE_LABEL = {
1631
+ to: "hop.sent",
1632
+ cc: "hop.cc",
1633
+ author: "hop.contributed",
1634
+ thread: "detail.thread"
1635
+ };
1636
+ /**
1637
+ * Transfer-path hop action → dictionary key.
1638
+ *
1639
+ * These are ledger vocabulary: the model and the ledger files keep the raw
1640
+ * tokens (`authored`, `delivered`, …), and only the human view speaks a
1641
+ * language. Keyed by the full action union so a new hop action cannot ship
1642
+ * without a word for it.
1643
+ */
1644
+ /** Lease-state description, for the checkout chip's tooltip. */
1645
+ const LEASE_HINT = {
1646
+ held: "row.leaseHeld",
1647
+ released: "row.leaseReleased",
1648
+ reclaimed: "row.leaseReclaimed",
1649
+ none: "row.leaseNone"
1650
+ };
1651
+ /** Hop action label, for the transfer-path rendering. */
1652
+ const HOP_LABEL = {
1653
+ authored: "hop.authored",
1654
+ contributed: "hop.contributed",
1655
+ sent: "hop.sent",
1656
+ delivered: "hop.delivered",
1657
+ cc: "hop.cc",
1658
+ forwarded: "hop.forwarded",
1659
+ copied: "hop.copied",
1660
+ read: "hop.read",
1661
+ acked: "hop.acked",
1662
+ leased: "hop.leased",
1663
+ released: "hop.released",
1664
+ reclaimed: "hop.reclaimed",
1665
+ amended: "hop.amended",
1666
+ closed: "hop.closed",
1667
+ reopened: "hop.reopened"
1668
+ };
1669
+ //#endregion
1670
+ //#region src/client/CardgraphView.tsx
1671
+ /**
1672
+ * The card canvas's view: two canvases, a pan/zoom transform, picking, and an
1673
+ * invisible button layer.
1674
+ *
1675
+ * The drawing is split across two surfaces, and that split is the whole
1676
+ * performance story:
1677
+ *
1678
+ * - the **base** canvas holds frames, wires and cards, and is repainted only when
1679
+ * the drawing itself changes (layout, transform, size, theme);
1680
+ * - the **interaction** canvas holds one report's path over a scrim, and is
1681
+ * repainted on every hover. Its cost does not grow with the ledger, because it
1682
+ * never touches the shapes underneath.
1683
+ *
1684
+ * Picking is arithmetic rather than elements: the canvas has nothing to hit, so
1685
+ * `hitTest` from the model is given the cards (topmost first) and then the frames.
1686
+ * What the canvas also cannot offer is accessibility — so every card that is on
1687
+ * screen gets a real, transparent `<button>` on top of it, which is what a keyboard
1688
+ * or a screen reader reaches, and the list below stays the full accessible path.
1689
+ *
1690
+ * @module dsh-report-ledger/client/CardgraphView
1691
+ */
1692
+ /** How far the reader may zoom out and in. */
1693
+ const MIN_ZOOM = .25;
1694
+ const MAX_ZOOM = 2.5;
1695
+ /** One wheel notch. */
1696
+ const ZOOM_STEP = 1.25;
1697
+ /** Drawing units to keep beyond the viewport, so a pan does not reveal a blank band. */
1698
+ const OVERSCAN = 160;
1699
+ /** How tall the canvas box is, inside the tab. */
1700
+ const VIEW_HEIGHT = 420;
1701
+ /** How far the pointer may travel before a press becomes a drag instead of a click. */
1702
+ const DRAG_SLOP = 4;
1703
+ /** The four kinds, in the order the legend lists them. */
1704
+ const KINDS = [
1705
+ "to",
1706
+ "cc",
1707
+ "author",
1708
+ "thread"
1709
+ ];
1710
+ /**
1711
+ * The legend.
1712
+ *
1713
+ * Built from the painter's own wire table (`WIRE_TOKENS`) rather than from CSS, so
1714
+ * a legend that disagreed with the lines beside it would take a change to that one
1715
+ * table — the drift the swimlane's legend avoided a different way (by sharing a
1716
+ * class name with its paths).
1717
+ * @param props - the translator.
1718
+ * @returns the legend row.
1719
+ */
1720
+ function CardgraphLegend({ t }) {
1721
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
1722
+ style: {
1723
+ display: "flex",
1724
+ alignItems: "center",
1725
+ gap: "12px",
1726
+ flexWrap: "wrap"
1727
+ },
1728
+ children: KINDS.map((kind) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
1729
+ style: {
1730
+ display: "inline-flex",
1731
+ alignItems: "center",
1732
+ gap: "6px",
1733
+ fontSize: "var(--dsw-font-xxxs-11, 11px)",
1734
+ color: "var(--dsw-alias-label-caption)"
1735
+ },
1736
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
1737
+ width: "26",
1738
+ height: "8",
1739
+ "aria-hidden": "true",
1740
+ focusable: "false",
1741
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("line", {
1742
+ x1: "0",
1743
+ y1: "4",
1744
+ x2: "26",
1745
+ y2: "4",
1746
+ stroke: `var(${WIRE_TOKENS[kind].colour})`,
1747
+ strokeWidth: WIRE_TOKENS[kind].width,
1748
+ ...WIRE_TOKENS[kind].dash.length === 0 ? {} : { strokeDasharray: WIRE_TOKENS[kind].dash.join(" ") }
1749
+ })
1750
+ }), t(EDGE_LABEL[kind])]
1751
+ }, kind))
1752
+ });
1753
+ }
1754
+ /** Keep a number inside a range. */
1755
+ const clamp = (value, low, high) => Math.min(high, Math.max(low, value));
1756
+ /**
1757
+ * The drawing.
1758
+ * @param props - payload, the reports to plot, translations, and the open handler.
1759
+ * @returns the canvas box with its controls.
1760
+ */
1761
+ function CardgraphView({ payload, reports, t, openReport, onOpen, idHint }) {
1762
+ const boxRef = (0, react.useRef)(null);
1763
+ const baseRef = (0, react.useRef)(null);
1764
+ const pathRef = (0, react.useRef)(null);
1765
+ const painter = (0, react.useRef)(null);
1766
+ const pathPainter = (0, react.useRef)(null);
1767
+ const reader = (0, react.useRef)(null);
1768
+ const drag = (0, react.useRef)(null);
1769
+ const [size, setSize] = (0, react.useState)({
1770
+ width: 0,
1771
+ height: VIEW_HEIGHT
1772
+ });
1773
+ const [zoom, setZoom] = (0, react.useState)(1);
1774
+ const [offset, setOffset] = (0, react.useState)({
1775
+ x: 0,
1776
+ y: 0
1777
+ });
1778
+ const [hover, setHover] = (0, react.useState)({});
1779
+ const [ready, setReady] = (0, react.useState)(false);
1780
+ const fitted = (0, react.useRef)(false);
1781
+ /** The transform as of this render, for the one listener that is attached once. */
1782
+ const zoomRef = (0, react.useRef)(zoom);
1783
+ const offsetRef = (0, react.useRef)(offset);
1784
+ zoomRef.current = zoom;
1785
+ offsetRef.current = offset;
1786
+ const lod = lodFor(zoom);
1787
+ const layout = (0, react.useMemo)(() => buildCardgraph(payload, reports, { lod }), [
1788
+ payload,
1789
+ reports,
1790
+ lod
1791
+ ]);
1792
+ const labels = (0, react.useMemo)(() => {
1793
+ const status = {};
1794
+ for (const [state, key] of Object.entries(STATUS_LABEL)) status[state] = t(key);
1795
+ return {
1796
+ external: t("topology.external"),
1797
+ empty: t("cardgraph.empty"),
1798
+ status
1799
+ };
1800
+ }, [t]);
1801
+ (0, react.useEffect)(() => {
1802
+ const base = baseRef.current;
1803
+ const path = pathRef.current;
1804
+ if (base === null || path === null) return;
1805
+ painter.current = createCanvasPainter(base);
1806
+ pathPainter.current = createCanvasPainter(path);
1807
+ reader.current = createThemeReader(globalThis.document.documentElement, globalThis.window);
1808
+ setReady(true);
1809
+ return () => {
1810
+ painter.current = null;
1811
+ pathPainter.current = null;
1812
+ reader.current = null;
1813
+ setReady(false);
1814
+ };
1815
+ }, []);
1816
+ (0, react.useEffect)(() => {
1817
+ const box = boxRef.current;
1818
+ if (box === null) return;
1819
+ const measure = () => {
1820
+ setSize({
1821
+ width: box.clientWidth,
1822
+ height: box.clientHeight
1823
+ });
1824
+ };
1825
+ measure();
1826
+ const observer = new ResizeObserver(measure);
1827
+ observer.observe(box);
1828
+ return () => {
1829
+ observer.disconnect();
1830
+ };
1831
+ }, []);
1832
+ const viewport = (0, react.useMemo)(() => ({
1833
+ offsetX: offset.x,
1834
+ offsetY: offset.y,
1835
+ scale: zoom,
1836
+ width: size.width,
1837
+ height: size.height
1838
+ }), [
1839
+ offset,
1840
+ zoom,
1841
+ size
1842
+ ]);
1843
+ const focus = (0, react.useMemo)(() => ({
1844
+ ...hover.report === void 0 ? {} : { report: hover.report },
1845
+ ...hover.frame === void 0 ? {} : { frame: hover.frame },
1846
+ ...openReport === void 0 ? {} : { selected: openReport }
1847
+ }), [hover, openReport]);
1848
+ const scene = (0, react.useMemo)(() => ({
1849
+ layout,
1850
+ theme: reader.current?.read() ?? FALLBACK_THEME,
1851
+ viewport,
1852
+ focus,
1853
+ labels,
1854
+ overscan: OVERSCAN
1855
+ }), [
1856
+ layout,
1857
+ viewport,
1858
+ focus,
1859
+ labels
1860
+ ]);
1861
+ (0, react.useEffect)(() => {
1862
+ const target = painter.current;
1863
+ if (!ready || target === null) return;
1864
+ target.resize(size.width, size.height, globalThis.devicePixelRatio || 1);
1865
+ target.paint({
1866
+ ...scene,
1867
+ focus: openReport === void 0 ? {} : { selected: openReport }
1868
+ });
1869
+ }, [
1870
+ ready,
1871
+ scene,
1872
+ size,
1873
+ openReport
1874
+ ]);
1875
+ (0, react.useEffect)(() => {
1876
+ const target = pathPainter.current;
1877
+ if (!ready || target === null) return;
1878
+ target.resize(size.width, size.height, globalThis.devicePixelRatio || 1);
1879
+ target.paintPath(scene);
1880
+ }, [
1881
+ ready,
1882
+ scene,
1883
+ size
1884
+ ]);
1885
+ /** Everything under the pointer, topmost first: cards, then the frames holding them. */
1886
+ const targets = (0, react.useMemo)(() => [...[...layout.cards].reverse().map((card) => ({
1887
+ kind: "card",
1888
+ report: card.report,
1889
+ bounds: card.bounds
1890
+ })), ...[...layout.frames].reverse().map((frame) => ({
1891
+ kind: "frame",
1892
+ frame: frame.index,
1893
+ bounds: frame.bounds
1894
+ }))], [layout]);
1895
+ const pick = (0, react.useCallback)((clientX, clientY) => {
1896
+ const box = boxRef.current;
1897
+ if (box === null) return void 0;
1898
+ const rect = box.getBoundingClientRect();
1899
+ const x = (clientX - rect.left - offset.x) / zoom;
1900
+ const y = (clientY - rect.top - offset.y) / zoom;
1901
+ return hitTest(targets, x, y);
1902
+ }, [
1903
+ targets,
1904
+ offset,
1905
+ zoom
1906
+ ]);
1907
+ const fit = (0, react.useCallback)(() => {
1908
+ const box = boxRef.current;
1909
+ if (box === null || layout.width <= 0 || layout.height <= 0) return;
1910
+ const width = box.clientWidth;
1911
+ const height = box.clientHeight;
1912
+ const next = clamp(Math.min((width - 24) / layout.width, (height - 24) / layout.height), MIN_ZOOM, 1);
1913
+ setZoom(next);
1914
+ setOffset({
1915
+ x: Math.max(8, (width - layout.width * next) / 2),
1916
+ y: Math.max(8, (height - layout.height * next) / 2)
1917
+ });
1918
+ }, [layout]);
1919
+ /**
1920
+ * The first view, which is deliberately **not** the fit.
1921
+ *
1922
+ * Fitting the whole drawing into a 420px box on a three-column tree lands around
1923
+ * 57%, which is the compact tier — so the reader's first sight of a card canvas
1924
+ * would be a wall of one-line cards with the subjects this design is about elided.
1925
+ * Measured on the real tab, not reasoned about: the first screenshot of the wiring
1926
+ * came out at 57% and proved the point.
1927
+ *
1928
+ * So the first view fits the **width** and never drops below the card tier: frames,
1929
+ * columns and readable cards all visible at once, and the reader scrolls down
1930
+ * instead of squinting. `适应窗口` still gives the whole picture on demand.
1931
+ */
1932
+ (0, react.useEffect)(() => {
1933
+ if (fitted.current || size.width === 0 || layout.cards.length === 0) return;
1934
+ fitted.current = true;
1935
+ const next = clamp((size.width - 24) / Math.max(layout.width, 1), .8, 1);
1936
+ setZoom(next);
1937
+ setOffset({
1938
+ x: Math.max(8, (size.width - layout.width * next) / 2),
1939
+ y: 12
1940
+ });
1941
+ }, [layout, size]);
1942
+ (0, react.useEffect)(() => {
1943
+ const box = boxRef.current;
1944
+ if (box === null) return;
1945
+ const onWheel = (event) => {
1946
+ event.preventDefault();
1947
+ const rect = box.getBoundingClientRect();
1948
+ const px = event.clientX - rect.left;
1949
+ const py = event.clientY - rect.top;
1950
+ const current = zoomRef.current;
1951
+ const next = clamp(current * (event.deltaY < 0 ? ZOOM_STEP : 1 / ZOOM_STEP), MIN_ZOOM, MAX_ZOOM);
1952
+ const ratio = next / current;
1953
+ const previous = offsetRef.current;
1954
+ setOffset({
1955
+ x: px - (px - previous.x) * ratio,
1956
+ y: py - (py - previous.y) * ratio
1957
+ });
1958
+ setZoom(next);
1959
+ };
1960
+ box.addEventListener("wheel", onWheel, { passive: false });
1961
+ return () => {
1962
+ box.removeEventListener("wheel", onWheel);
1963
+ };
1964
+ }, []);
1965
+ /** Drag to pan, click to open. Distinguishing them by distance travelled. */
1966
+ const onPointerDown = (0, react.useCallback)((event) => {
1967
+ if (event.button !== 0) return;
1968
+ drag.current = {
1969
+ x: event.clientX,
1970
+ y: event.clientY,
1971
+ offsetX: offset.x,
1972
+ offsetY: offset.y,
1973
+ moved: false
1974
+ };
1975
+ }, [offset]);
1976
+ const onPointerMove = (0, react.useCallback)((event) => {
1977
+ const pressing = drag.current;
1978
+ if (pressing !== null) {
1979
+ const dx = event.clientX - pressing.x;
1980
+ const dy = event.clientY - pressing.y;
1981
+ if (Math.abs(dx) + Math.abs(dy) > DRAG_SLOP) pressing.moved = true;
1982
+ if (pressing.moved) {
1983
+ setOffset({
1984
+ x: pressing.offsetX + dx,
1985
+ y: pressing.offsetY + dy
1986
+ });
1987
+ return;
1988
+ }
1989
+ }
1990
+ const hit = pick(event.clientX, event.clientY);
1991
+ setHover(hit?.kind === "card" && hit.report !== void 0 ? { report: hit.report } : hit?.kind === "frame" && hit.frame !== void 0 ? { frame: hit.frame } : {});
1992
+ }, [pick]);
1993
+ const onPointerUp = (0, react.useCallback)((event) => {
1994
+ const pressing = drag.current;
1995
+ drag.current = null;
1996
+ if (pressing === null || pressing.moved) return;
1997
+ const hit = pick(event.clientX, event.clientY);
1998
+ if (hit?.kind === "card" && hit.report !== void 0) onOpen(hit.report);
1999
+ }, [pick, onOpen]);
2000
+ const onPointerLeave = (0, react.useCallback)(() => {
2001
+ drag.current = null;
2002
+ setHover({});
2003
+ }, []);
2004
+ const visibleCards = (0, react.useMemo)(() => {
2005
+ const left = -offset.x / zoom;
2006
+ const top = -offset.y / zoom;
2007
+ const right = (size.width - offset.x) / zoom;
2008
+ const bottom = (size.height - offset.y) / zoom;
2009
+ 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);
2010
+ }, [
2011
+ layout,
2012
+ offset,
2013
+ zoom,
2014
+ size
2015
+ ]);
2016
+ const percent = Math.round(zoom * 100);
2017
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2018
+ style: {
2019
+ display: "flex",
2020
+ flexDirection: "column",
2021
+ gap: "6px"
2022
+ },
2023
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2024
+ style: controls,
2025
+ children: [
2026
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2027
+ variant: "outline",
2028
+ size: "sm",
2029
+ "aria-label": t("cardgraph.zoomOut"),
2030
+ onClick: () => {
2031
+ setZoom((z) => clamp(z / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
2032
+ },
2033
+ children: "−"
2034
+ }),
2035
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2036
+ style: percentStyle,
2037
+ children: t("cardgraph.zoom", { percent })
2038
+ }),
2039
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2040
+ variant: "outline",
2041
+ size: "sm",
2042
+ "aria-label": t("cardgraph.zoomIn"),
2043
+ onClick: () => {
2044
+ setZoom((z) => clamp(z * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM));
2045
+ },
2046
+ children: "+"
2047
+ }),
2048
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2049
+ variant: "outline",
2050
+ size: "sm",
2051
+ onClick: fit,
2052
+ children: t("cardgraph.fit")
2053
+ }),
2054
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardgraphLegend, { t }),
2055
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2056
+ style: percentStyle,
2057
+ children: t("cardgraph.summary", {
2058
+ frames: layout.frames.length,
2059
+ cards: layout.cards.length
2060
+ })
2061
+ })
2062
+ ]
2063
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2064
+ ref: boxRef,
2065
+ onPointerDown,
2066
+ onPointerMove,
2067
+ onPointerUp,
2068
+ onPointerLeave,
2069
+ style: {
2070
+ ...box,
2071
+ height: `${VIEW_HEIGHT}px`,
2072
+ cursor: drag.current?.moved === true ? "grabbing" : hover.report === void 0 ? "grab" : "pointer"
2073
+ },
2074
+ children: [
2075
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
2076
+ ref: baseRef,
2077
+ role: "img",
2078
+ "aria-label": t("cardgraph.summary", {
2079
+ frames: layout.frames.length,
2080
+ cards: layout.cards.length
2081
+ }),
2082
+ style: layer
2083
+ }),
2084
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("canvas", {
2085
+ ref: pathRef,
2086
+ "aria-hidden": "true",
2087
+ style: layer
2088
+ }),
2089
+ visibleCards.map((card) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
2090
+ type: "button",
2091
+ "aria-label": `${card.report} · ${card.subject}`,
2092
+ title: idHint(card.report),
2093
+ onClick: () => {
2094
+ onOpen(card.report);
2095
+ },
2096
+ onFocus: () => {
2097
+ setHover({ report: card.report });
2098
+ },
2099
+ onBlur: () => {
2100
+ setHover({});
2101
+ },
2102
+ style: {
2103
+ position: "absolute",
2104
+ left: `${card.bounds.x * zoom + offset.x}px`,
2105
+ top: `${card.bounds.y * zoom + offset.y}px`,
2106
+ width: `${card.bounds.width * zoom}px`,
2107
+ height: `${card.bounds.height * zoom}px`,
2108
+ padding: 0,
2109
+ border: 0,
2110
+ background: "transparent",
2111
+ cursor: "pointer"
2112
+ }
2113
+ }, card.report))
2114
+ ]
2115
+ })]
2116
+ });
2117
+ }
2118
+ const controls = {
2119
+ display: "flex",
2120
+ alignItems: "center",
2121
+ gap: "8px",
2122
+ flexWrap: "wrap"
2123
+ };
2124
+ const percentStyle = {
2125
+ fontSize: "var(--dsw-font-xxxs-11, 11px)",
2126
+ color: "var(--dsw-alias-label-caption)",
2127
+ fontVariantNumeric: "tabular-nums"
2128
+ };
2129
+ const box = {
2130
+ position: "relative",
2131
+ overflow: "hidden",
2132
+ borderRadius: "6px",
2133
+ border: "1px solid var(--dsw-alias-border-l1)",
2134
+ touchAction: "none"
2135
+ };
2136
+ const layer = {
2137
+ position: "absolute",
2138
+ inset: "0",
2139
+ display: "block"
2140
+ };
2141
+ //#endregion
2142
+ //#region src/client/styles.ts
2143
+ /**
2144
+ * The plugin's own stylesheet.
128
2145
  *
129
- * A thread link can point outside the subtree — a report answering one opened by
130
- * an unrelated session, for instance — and the detail endpoint is ledger-scoped,
131
- * so such a report still opens. Knowing which ids are in the list is what lets
132
- * the view say so instead of silently showing a card that is not in the timeline.
133
- * @param payload - the endpoint payload.
134
- * @returns the ids of every report in the payload.
2146
+ * The shell's primitives ship their own CSS with the shell, which is the whole
2147
+ * point of using them — a plugin gets the app's real look without carrying a
2148
+ * theme. What is left for us are the two things only a consumer can know:
2149
+ *
2150
+ * 1. **Layout that belongs to the consumer.** `Input` sizes itself to its
2151
+ * content (`display: inline-flex`, no width), which is right in a form and
2152
+ * wrong in this tab's toolbar, where the search seat takes the slack. The
2153
+ * component puts the `className` it is given on its own wrapper, so one rule
2154
+ * is enough — and targeting a class of our own keeps us off the hashed class
2155
+ * names, which are free to change between shell releases.
2156
+ *
2157
+ * 2. **Drawing the topology.** The graph is made of elements the shell has no
2158
+ * primitive for — edges, lane rails, row separators, the hover dimming — so
2159
+ * these rules style OUR OWN svg and divs. Nothing here restyles a shell
2160
+ * component; every library element keeps the look the shell gave it.
2161
+ *
2162
+ * Every colour comes from a `--dsw-*` token, so the drawing follows the theme
2163
+ * (light or dark) without knowing anything about it.
2164
+ *
2165
+ * @module dsh-report-ledger/client/styles
135
2166
  */
136
- function reportIds(payload) {
137
- const ids = /* @__PURE__ */ new Set();
138
- for (const front of payload.reports) ids.add(front.report);
139
- return ids;
140
- }
141
- /** Body length above which the detail panel shows only a head. */
142
- const BODY_DISPLAY_LIMIT = 4e3;
143
- /** How much of an over-long body is shown. */
144
- const BODY_HEAD_LIMIT = 1200;
2167
+ /** Class the tab puts on the toolbar's search seat. */
2168
+ const SEARCH_CLASS = "report-ledger-search";
2169
+ /** Applied to everything that is not part of the hovered report's path. */
2170
+ const DIM_CLASS = "report-ledger-dim";
2171
+ /** Element id, so a reload replaces the sheet instead of stacking one per apply. */
2172
+ const STYLE_ID = "dsh-report-ledger-styles";
2173
+ /** The adapter rules, kept in one place so the id and the text cannot drift. */
2174
+ const CSS = `
2175
+ .${SEARCH_CLASS} { flex: 1 1 auto; min-width: 0; }
2176
+
2177
+ /* ── topology: our own drawing primitives ──────────────────────────────────
2178
+ Rails and row separators are hairlines; edges carry the relation, so only
2179
+ they get colour and dash pattern. Everything reads through theme tokens. */
2180
+ .report-ledger-rail { stroke: var(--dsw-alias-border-l1); stroke-width: 1; }
2181
+ .report-ledger-rowsep { stroke: var(--dsw-alias-border-l1); stroke-width: 1; opacity: .45; }
2182
+ .report-ledger-headsep { stroke: var(--dsw-alias-border-l2); stroke-width: 1; }
2183
+ .report-ledger-edge { fill: none; }
2184
+ .report-ledger-edge-to { stroke: var(--dsw-alias-label-secondary); stroke-width: 1.4; }
2185
+ .report-ledger-edge-cc { stroke: var(--dsw-alias-label-caption); stroke-width: 1; stroke-dasharray: 2 3; }
2186
+ .report-ledger-edge-author { stroke: var(--dsw-alias-label-caption); stroke-width: 1; stroke-dasharray: 1 3; }
2187
+ .report-ledger-edge-thread { stroke: var(--dsw-alias-state-business-label, var(--dsw-alias-label-secondary)); stroke-width: 1.2; }
2188
+ .report-ledger-arrow-to { fill: var(--dsw-alias-label-secondary); }
2189
+ .report-ledger-arrow-cc { fill: var(--dsw-alias-label-caption); }
2190
+ .report-ledger-arrow-author { fill: var(--dsw-alias-label-caption); }
2191
+ .report-ledger-arrow-thread { fill: var(--dsw-alias-state-business-label, var(--dsw-alias-label-secondary)); }
2192
+ /* A hover label over a line needs its own backing, or the line runs through the word. */
2193
+ .report-ledger-edge-label {
2194
+ padding: 0 4px;
2195
+ border-radius: 3px;
2196
+ font-size: var(--dsw-font-xxxs-11, 11px);
2197
+ line-height: 15px;
2198
+ white-space: nowrap;
2199
+ color: var(--dsw-alias-label-secondary);
2200
+ background: var(--dsw-alias-bg-base);
2201
+ }
2202
+ /* ── hover focus ───────────────────────────────────────────────────────────
2203
+ The dim goes on ONE element — the edge layer — and never through a descendant
2204
+ selector. Measured on a 300-report drawing: flipping a data attribute that a
2205
+ descendant rule matches costs ~35ms per hover on its own, because the browser
2206
+ recalculates style for the whole subtree whether or not the result changes
2207
+ anything visible; dimming 831 individual groups cost ~89ms. One class on one
2208
+ layer costs neither.
2209
+
2210
+ Nodes are deliberately NOT dimmed: while tracing one report's path it is the
2211
+ *relations* that need to recede, not the participants. */
2212
+ .${DIM_CLASS} { opacity: .18; }
2213
+ `;
145
2214
  /**
146
- * Trim a body for the detail panel.
147
- *
148
- * Mirrors what `report_read` already does for the model: the full text stays in
149
- * the ledger file, and both readers get a bounded view plus a pointer rather than
150
- * an unbounded paste. Keeping the human view and the model view bounded the same
151
- * way means neither can be surprised by what the other sees.
152
- * @param body - the raw body.
153
- * @param limit - length above which truncation applies.
154
- * @param head - how many characters to keep when truncating.
155
- * @returns the display text and whether it was cut.
2215
+ * Mount the adapter stylesheet.
2216
+ * @returns a disposer that removes it, owned by the plugin's effect scope.
156
2217
  */
157
- function displayBody(body, limit = BODY_DISPLAY_LIMIT, head = BODY_HEAD_LIMIT) {
158
- if (body.length <= limit) return {
159
- text: body,
160
- truncated: false
161
- };
162
- return {
163
- text: body.slice(0, head),
164
- truncated: true
2218
+ function installStyles() {
2219
+ const doc = globalThis.document;
2220
+ if (doc === void 0) return () => {};
2221
+ doc.getElementById(STYLE_ID)?.remove();
2222
+ const style = doc.createElement("style");
2223
+ style.id = STYLE_ID;
2224
+ style.textContent = CSS;
2225
+ doc.head.append(style);
2226
+ return () => {
2227
+ style.remove();
165
2228
  };
166
2229
  }
167
2230
  //#endregion
@@ -186,6 +2249,15 @@ window.__ModuleLoader__.load({
186
2249
  */
187
2250
  const TIMELINE_URL = "/api/report-ledger/timeline";
188
2251
  const REPORT_URL = "/api/report-ledger/report";
2252
+ /**
2253
+ * How often the tab re-reads the timeline while it is on screen.
2254
+ *
2255
+ * The ledger is written by other agents in the background, so a view that loads
2256
+ * once goes stale precisely when it matters — the human is watching for a report
2257
+ * that has not been written yet. Ten seconds is quiet enough not to matter on a
2258
+ * large ledger and still fast enough to watch a collaboration happen.
2259
+ */
2260
+ const POLL_INTERVAL_MS = 1e4;
189
2261
  /** Read one endpoint and unwrap its result envelope. */
190
2262
  async function getJson(url) {
191
2263
  const response = await fetch(url, {
@@ -211,21 +2283,17 @@ window.__ModuleLoader__.load({
211
2283
  if (at === void 0 || at <= 0) return "";
212
2284
  return new Date(at).toLocaleString();
213
2285
  }
214
- /** Colour a status chip from the theme's state tokens. */
215
- function statusStyle(status) {
216
- if (status === "acked") return {
217
- color: "var(--dsw-alias-state-success-label, #1a7f37)",
218
- background: "var(--dsw-alias-state-success-bg, rgba(26,127,55,0.10))"
219
- };
220
- if (status === "closed") return {
221
- color: "var(--dsw-alias-label-tertiary, #888)",
222
- background: "var(--dsw-alias-bg-layer-2, rgba(0,0,0,0.04))"
223
- };
224
- return {
225
- color: "var(--dsw-alias-state-warn-label, #9a6700)",
226
- background: "var(--dsw-alias-state-warn-bg, rgba(154,103,0,0.10))"
227
- };
2286
+ /** Dom id of one report's card, so the topology can scroll to it. */
2287
+ function rowDomId(report) {
2288
+ return `report-ledger-row-${report}`;
228
2289
  }
2290
+ /**
2291
+ * How long the copy control keeps its "copied" label.
2292
+ *
2293
+ * The chat view uses the same one-second acknowledgement through the same
2294
+ * `writeClipboard`, so a copy here feels like a copy there.
2295
+ */
2296
+ const COPIED_HOLD_MS = 1e3;
229
2297
  /** Storage key for the remembered status filter. */
230
2298
  const FILTER_KEY = "dsh.reportLedger.filter.v1";
231
2299
  /** Read the remembered status filter, tolerating absent or damaged storage. */
@@ -248,13 +2316,22 @@ window.__ModuleLoader__.load({
248
2316
  const { sessionId, t } = props;
249
2317
  const [payload, setPayload] = (0, react.useState)(void 0);
250
2318
  const [error, setError] = (0, react.useState)(void 0);
251
- const [nonce, setNonce] = (0, react.useState)(0);
2319
+ const [timelineNonce, setTimelineNonce] = (0, react.useState)(0);
2320
+ const [detailNonce, setDetailNonce] = (0, react.useState)(0);
252
2321
  const [openReport, setOpenReport] = (0, react.useState)(void 0);
253
2322
  const [detail, setDetail] = (0, react.useState)(void 0);
254
2323
  const [detailError, setDetailError] = (0, react.useState)(void 0);
255
2324
  const [status, setStatus] = (0, react.useState)(rememberedStatus);
256
2325
  const [text, setText] = (0, react.useState)("");
257
2326
  const [basis, setBasis] = (0, react.useState)("created");
2327
+ const [copiedPath, setCopiedPath] = (0, react.useState)(false);
2328
+ const copyTimer = (0, react.useRef)(void 0);
2329
+ const [showTopology, setShowTopology] = (0, react.useState)(true);
2330
+ /** Set when the topology asks for a card, so the list can scroll to it once. */
2331
+ const scrollTo = (0, react.useRef)(void 0);
2332
+ (0, react.useEffect)(() => () => {
2333
+ if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
2334
+ }, []);
258
2335
  (0, react.useEffect)(() => {
259
2336
  try {
260
2337
  globalThis.localStorage?.setItem(FILTER_KEY, JSON.stringify({ status }));
@@ -275,7 +2352,23 @@ window.__ModuleLoader__.load({
275
2352
  return () => {
276
2353
  cancelled = true;
277
2354
  };
278
- }, [sessionId, nonce]);
2355
+ }, [sessionId, timelineNonce]);
2356
+ (0, react.useEffect)(() => {
2357
+ if (sessionId === void 0 || sessionId === "") return void 0;
2358
+ const tick = () => {
2359
+ if (globalThis.document?.visibilityState === "hidden") return;
2360
+ setTimelineNonce((value) => value + 1);
2361
+ };
2362
+ const timer = globalThis.setInterval(tick, POLL_INTERVAL_MS);
2363
+ const onVisibility = () => {
2364
+ if (globalThis.document?.visibilityState !== "hidden") tick();
2365
+ };
2366
+ globalThis.document?.addEventListener("visibilitychange", onVisibility);
2367
+ return () => {
2368
+ globalThis.clearInterval(timer);
2369
+ globalThis.document?.removeEventListener("visibilitychange", onVisibility);
2370
+ };
2371
+ }, [sessionId]);
279
2372
  (0, react.useEffect)(() => {
280
2373
  if (openReport === void 0) return;
281
2374
  let cancelled = false;
@@ -289,11 +2382,24 @@ window.__ModuleLoader__.load({
289
2382
  return () => {
290
2383
  cancelled = true;
291
2384
  };
292
- }, [openReport, nonce]);
2385
+ }, [openReport, detailNonce]);
293
2386
  const rows = (0, react.useMemo)(() => payload === void 0 ? [] : buildRows(payload, basis), [payload, basis]);
294
2387
  const counts = (0, react.useMemo)(() => statusCounts(payload?.reports ?? []), [payload]);
295
2388
  const inList = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Set() : reportIds(payload), [payload]);
2389
+ const lanes = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : laneIndex(payload), [payload]);
296
2390
  const shownBody = (0, react.useMemo)(() => displayBody(detail?.body ?? ""), [detail]);
2391
+ const bodyText = (0, react.useMemo)(() => {
2392
+ const text = shownBody.text.trim();
2393
+ return shownBody.truncated ? closeOpenFence(text) : text;
2394
+ }, [shownBody]);
2395
+ /** Strings for the prose renderer's own controls, from this plugin's dictionary. */
2396
+ const markdownLabels = (0, react.useMemo)(() => ({
2397
+ code: {
2398
+ copyLabel: t("body.copy"),
2399
+ copiedLabel: t("body.copied")
2400
+ },
2401
+ footnotes: t("body.footnotes")
2402
+ }), [t]);
297
2403
  const filter = (0, react.useMemo)(() => ({
298
2404
  status,
299
2405
  text
@@ -301,18 +2407,13 @@ window.__ModuleLoader__.load({
301
2407
  const visible = (0, react.useMemo)(() => {
302
2408
  const kept = filterRows(rows, filter);
303
2409
  if (openReport === void 0 || detail === void 0) return kept;
304
- if (kept.some((row) => row.kind === "report" && row.front.report === openReport)) return kept;
305
- return [...kept, {
306
- kind: "report",
307
- at: detail.front.created,
308
- depth: 0,
309
- front: detail.front
310
- }];
2410
+ return withSynthesizedRow(kept, detail.front, basis);
311
2411
  }, [
312
2412
  rows,
313
2413
  filter,
314
2414
  openReport,
315
- detail
2415
+ detail,
2416
+ basis
316
2417
  ]);
317
2418
  const filteredReportIds = (0, react.useMemo)(() => {
318
2419
  const ids = /* @__PURE__ */ new Set();
@@ -321,8 +2422,75 @@ window.__ModuleLoader__.load({
321
2422
  }, [rows, filter]);
322
2423
  const filtering = isFiltering(filter);
323
2424
  const visibleReportCount = (0, react.useMemo)(() => visibleReports(rows, filter), [rows, filter]);
2425
+ const empty = (0, react.useMemo)(() => payload === void 0 ? "none" : emptyState(payload, rows, filter), [
2426
+ payload,
2427
+ rows,
2428
+ filter
2429
+ ]);
2430
+ const titles = (0, react.useMemo)(() => payload === void 0 ? /* @__PURE__ */ new Map() : sessionTitles(payload), [payload]);
2431
+ const idHint = (0, react.useCallback)((id) => {
2432
+ const title = titles.get(id);
2433
+ return title === void 0 ? id : `${title} · ${id}`;
2434
+ }, [titles]);
2435
+ /**
2436
+ * The reports the topology plots.
2437
+ *
2438
+ * Taken from the same filtered row set the list renders, so the graph is a view
2439
+ * of the current filter rather than a second source of truth — narrowing the
2440
+ * toolbar narrows both.
2441
+ */
2442
+ const plotted = (0, react.useMemo)(() => filterRows(rows, filter).flatMap((row) => row.kind === "report" ? [row.front] : []), [rows, filter]);
2443
+ /**
2444
+ * One session id the way a dense row shows it: shortened, with the full value
2445
+ * and the session title on hover. The hover is not decoration — it is where
2446
+ * the human name lives, because titles are unbounded and a row is not.
2447
+ */
2448
+ const idRef = (id) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2449
+ label: idHint(id),
2450
+ side: "bottom",
2451
+ delayMs: 400,
2452
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: shortId(id) })
2453
+ });
324
2454
  const refresh = (0, react.useCallback)(() => {
325
- setNonce((value) => value + 1);
2455
+ setTimelineNonce((value) => value + 1);
2456
+ setDetailNonce((value) => value + 1);
2457
+ }, []);
2458
+ /**
2459
+ * Open a report from the topology.
2460
+ *
2461
+ * The graph does not get its own detail surface: it opens the card in the list
2462
+ * and brings it into view, which keeps one reading surface for the body and the
2463
+ * transfer path — and inherits the card's guarantees for free (a filtered-out
2464
+ * report still gets a row to render into).
2465
+ */
2466
+ const openFromTopology = (0, react.useCallback)((report) => {
2467
+ scrollTo.current = report;
2468
+ setOpenReport(report);
2469
+ }, []);
2470
+ (0, react.useEffect)(() => {
2471
+ const target = scrollTo.current;
2472
+ if (target === void 0 || openReport !== target) return;
2473
+ scrollTo.current = void 0;
2474
+ globalThis.document?.getElementById(rowDomId(target))?.scrollIntoView({
2475
+ block: "center",
2476
+ behavior: "smooth"
2477
+ });
2478
+ }, [openReport]);
2479
+ /**
2480
+ * Copy the ledger path, acknowledging through the control's own label.
2481
+ *
2482
+ * Uses the shell's `writeClipboard` rather than `navigator.clipboard`, so the
2483
+ * plugin inherits whatever fallback chain the rest of the GUI relies on.
2484
+ */
2485
+ const copyPath = (0, react.useCallback)((path) => {
2486
+ (0, _deepseek_ai_dsh_client_ui_primitives.writeClipboard)(path).then((ok) => {
2487
+ if (!ok) return;
2488
+ setCopiedPath(true);
2489
+ if (copyTimer.current !== void 0) globalThis.clearTimeout(copyTimer.current);
2490
+ copyTimer.current = globalThis.setTimeout(() => {
2491
+ setCopiedPath(false);
2492
+ }, COPIED_HOLD_MS);
2493
+ });
326
2494
  }, []);
327
2495
  const shell = {
328
2496
  padding: "16px 20px",
@@ -342,24 +2510,17 @@ window.__ModuleLoader__.load({
342
2510
  fontSize: "var(--dsw-font-xxs-12, 12)",
343
2511
  flex: "1 1 auto"
344
2512
  };
345
- const button = {
346
- font: "inherit",
347
- color: "var(--dsw-alias-label-secondary, inherit)",
348
- background: "var(--dsw-alias-bg-layer-2, transparent)",
349
- border: "1px solid var(--dsw-alias-border-l2, rgba(0,0,0,0.12))",
350
- borderRadius: "6px",
351
- padding: "3px 10px",
352
- cursor: "pointer"
2513
+ /**
2514
+ * The toolbar's search seat.
2515
+ *
2516
+ * `Input` renders its own wrapper around the field, so the flex sizing lives
2517
+ * on this outer box rather than on the control itself.
2518
+ */
2519
+ const searchBox = {
2520
+ flex: "1 1 200px",
2521
+ minWidth: "140px",
2522
+ display: "flex"
353
2523
  };
354
- const chip = (extra) => ({
355
- display: "inline-block",
356
- padding: "0 6px",
357
- borderRadius: "999px",
358
- fontSize: "var(--dsw-font-xxxs-11, 11)",
359
- lineHeight: "17px",
360
- marginRight: "6px",
361
- ...extra
362
- });
363
2524
  const tools = {
364
2525
  display: "flex",
365
2526
  alignItems: "center",
@@ -367,21 +2528,88 @@ window.__ModuleLoader__.load({
367
2528
  gap: "6px",
368
2529
  marginBottom: "10px"
369
2530
  };
2531
+ /** Chips sit next to a label; the shell's tags carry no spacing of their own. */
2532
+ const tagRow = {
2533
+ display: "inline-flex",
2534
+ gap: "4px",
2535
+ marginRight: "6px",
2536
+ verticalAlign: "baseline"
2537
+ };
2538
+ /** Keeps the state dot on the row's text baseline. */
2539
+ const dotCell = {
2540
+ display: "inline-flex",
2541
+ alignItems: "center"
2542
+ };
370
2543
  const time = {
371
2544
  color: "var(--dsw-alias-label-caption, #999)",
372
2545
  fontSize: "var(--dsw-font-xxxs-11, 11)",
373
2546
  whiteSpace: "nowrap"
374
2547
  };
2548
+ /**
2549
+ * The same caption look, for content that must be allowed to WRAP.
2550
+ *
2551
+ * `time` sets `white-space: nowrap`, which is right for a timestamp and wrong
2552
+ * for anything whose length the ledger decides. A nowrap element has a
2553
+ * min-content width equal to its whole unbreakable line, so inside a `1fr`
2554
+ * grid track — or a flex item without `min-width: 0` — it grows the track past
2555
+ * its container. Recipient lists and hop notes are exactly that: unbounded
2556
+ * text, which is how the transfer path used to push the whole tab into a
2557
+ * horizontal scrollbar and cut the notes off screen.
2558
+ */
2559
+ const wrap = {
2560
+ ...time,
2561
+ whiteSpace: "normal",
2562
+ minWidth: 0,
2563
+ overflowWrap: "anywhere"
2564
+ };
2565
+ /** The muted caption colour, for the empty-state sentences. */
2566
+ const muted = { color: "var(--dsw-alias-label-tertiary, #888)" };
2567
+ /** The frame the rendered body sits in — a block, not a scroll box. */
2568
+ const bodyFrame = {
2569
+ padding: "8px 10px",
2570
+ background: "var(--dsw-alias-bg-base, rgba(0,0,0,0.02))",
2571
+ border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
2572
+ borderRadius: "4px"
2573
+ };
2574
+ /** The transcript's footer line: ledger path plus its copy control. */
2575
+ const pathRow = {
2576
+ ...wrap,
2577
+ marginTop: "6px",
2578
+ display: "flex",
2579
+ alignItems: "baseline",
2580
+ gap: "6px"
2581
+ };
2582
+ /** The topology's frame: an overview that sits above the list, not instead of it. */
2583
+ const topoSection = {
2584
+ marginBottom: "12px",
2585
+ border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))",
2586
+ borderRadius: "6px",
2587
+ background: "var(--dsw-alias-bg-layer-1, rgba(0,0,0,0.02))",
2588
+ overflow: "hidden"
2589
+ };
2590
+ const topoHead = {
2591
+ display: "flex",
2592
+ alignItems: "center",
2593
+ gap: "10px",
2594
+ padding: "6px 10px",
2595
+ borderBottom: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
2596
+ };
375
2597
  if (error !== void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
376
2598
  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 })
2599
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2600
+ style: {
2601
+ display: "flex",
2602
+ alignItems: "center",
2603
+ gap: "6px"
2604
+ },
2605
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
2606
+ state: "error",
2607
+ size: 8
2608
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("view.error", { code: error }) })]
380
2609
  }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
381
2610
  style: { marginTop: "8px" },
382
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
383
- type: "button",
384
- style: button,
2611
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2612
+ variant: "outline",
385
2613
  onClick: refresh,
386
2614
  children: t("view.retry")
387
2615
  })
@@ -420,9 +2648,9 @@ window.__ModuleLoader__.load({
420
2648
  reports: payload.reports.length
421
2649
  })
422
2650
  }),
423
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
424
- type: "button",
425
- style: button,
2651
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2652
+ variant: "outline",
2653
+ icon: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.IconRefreshOutlineRegular, { size: 16 }),
426
2654
  onClick: refresh,
427
2655
  children: t("view.refresh")
428
2656
  })
@@ -437,72 +2665,79 @@ window.__ModuleLoader__.load({
437
2665
  "open",
438
2666
  "acked",
439
2667
  "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);
2668
+ ].map((candidate) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
2669
+ active: status === candidate,
2670
+ "aria-pressed": status === candidate,
2671
+ onClick: () => {
2672
+ setStatus(candidate);
466
2673
  },
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
- }
2674
+ children: `${t(statusLabel[candidate])} ${statusCount[candidate]}`
2675
+ }, candidate)),
2676
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2677
+ style: searchBox,
2678
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Input, {
2679
+ type: "search",
2680
+ className: SEARCH_CLASS,
2681
+ value: text,
2682
+ placeholder: t("filter.search"),
2683
+ "aria-label": t("filter.search"),
2684
+ onChange: (event) => {
2685
+ setText(event.target.value);
2686
+ }
2687
+ })
477
2688
  }),
478
- filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
479
- type: "button",
480
- style: button,
2689
+ filtering ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2690
+ variant: "outline",
481
2691
  onClick: () => {
482
2692
  setStatus("all");
483
2693
  setText("");
484
2694
  },
485
2695
  children: t("filter.clear")
486
2696
  }) : null,
487
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
488
- type: "button",
489
- style: button,
490
- title: t("basis.hint"),
491
- onClick: () => {
492
- setBasis(basis === "created" ? "updated" : "created");
493
- },
494
- children: t(basis === "created" ? "basis.created" : "basis.updated")
2697
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2698
+ label: t("basis.hint"),
2699
+ side: "bottom",
2700
+ delayMs: 400,
2701
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2702
+ variant: "outline",
2703
+ onClick: () => {
2704
+ setBasis(basis === "created" ? "updated" : "created");
2705
+ },
2706
+ children: t(basis === "created" ? "basis.created" : "basis.updated")
2707
+ })
495
2708
  })
496
2709
  ]
497
2710
  }),
498
- rows.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
499
- style: { color: "var(--dsw-alias-label-tertiary, #888)" },
2711
+ empty === "no-reports" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2712
+ style: muted,
500
2713
  children: t("view.empty")
501
2714
  }) : null,
502
- rows.length > 0 && visible.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
503
- style: { color: "var(--dsw-alias-label-tertiary, #888)" },
2715
+ empty === "filtered-out" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2716
+ style: muted,
504
2717
  children: t("filter.none")
505
2718
  }) : null,
2719
+ payload !== void 0 && plotted.length > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
2720
+ style: topoSection,
2721
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2722
+ style: topoHead,
2723
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("topology.title") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2724
+ variant: "ghost",
2725
+ size: "sm",
2726
+ icon: showTopology ? /* @__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 }),
2727
+ onClick: () => {
2728
+ setShowTopology((value) => !value);
2729
+ },
2730
+ children: showTopology ? t("topology.hide") : t("topology.show")
2731
+ })]
2732
+ }), showTopology ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardgraphView, {
2733
+ payload,
2734
+ reports: plotted,
2735
+ t,
2736
+ idHint,
2737
+ openReport,
2738
+ onOpen: openFromTopology
2739
+ }) : null]
2740
+ }) : null,
506
2741
  visible.map((row, index) => {
507
2742
  const pad = 8 + row.depth * 18;
508
2743
  if (row.kind === "session") return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
@@ -513,29 +2748,42 @@ window.__ModuleLoader__.load({
513
2748
  alignItems: "baseline"
514
2749
  },
515
2750
  children: [
516
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
517
- style: { color: "var(--dsw-alias-label-tertiary, #999)" },
518
- children: "●"
2751
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2752
+ label: idHint(row.id),
2753
+ side: "bottom",
2754
+ delayMs: 400,
2755
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2756
+ style: dotCell,
2757
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
2758
+ state: row.live ? "ongoing" : "idle",
2759
+ size: 8
2760
+ })
2761
+ })
519
2762
  }),
520
2763
  /* @__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
- }),
2764
+ style: {
2765
+ flex: "1 1 auto",
2766
+ minWidth: 0
2767
+ },
2768
+ children: [row.delegated || row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
2769
+ style: tagRow,
2770
+ children: [row.delegated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
2771
+ tone: "info",
528
2772
  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
- }),
2773
+ }) : null, row.live ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
2774
+ tone: "success",
535
2775
  children: t("view.live")
536
- }) : null,
537
- row.title ?? row.id
538
- ]
2776
+ }) : null]
2777
+ }) : null, row.title ?? shortId(row.id)]
2778
+ }),
2779
+ row.title === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2780
+ label: row.id,
2781
+ side: "right",
2782
+ delayMs: 400,
2783
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2784
+ style: time,
2785
+ children: shortId(row.id)
2786
+ })
539
2787
  }),
540
2788
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
541
2789
  style: time,
@@ -545,20 +2793,17 @@ window.__ModuleLoader__.load({
545
2793
  }, `s-${row.id}-${index}`);
546
2794
  const front = row.front;
547
2795
  const expanded = openReport === front.report;
2796
+ const panelId = `report-ledger-panel-${front.report}`;
2797
+ const wtLabel = worktreeLabel(front.worktree);
2798
+ const wtLane = lanes.get(front.report);
2799
+ const toggleLabel = fill(expanded ? t("row.collapse") : t("row.expand"), { report: front.report });
548
2800
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2801
+ id: rowDomId(front.report),
549
2802
  style: { paddingLeft: `${pad}px` },
550
2803
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
551
- role: "button",
552
- tabIndex: 0,
553
2804
  onClick: () => {
554
2805
  setOpenReport(expanded ? void 0 : front.report);
555
2806
  },
556
- onKeyDown: (event) => {
557
- if (event.key === "Enter" || event.key === " ") {
558
- event.preventDefault();
559
- setOpenReport(expanded ? void 0 : front.report);
560
- }
561
- },
562
2807
  style: {
563
2808
  display: "flex",
564
2809
  gap: "8px",
@@ -571,50 +2816,72 @@ window.__ModuleLoader__.load({
571
2816
  border: "1px solid var(--dsw-alias-border-l1, rgba(0,0,0,0.06))"
572
2817
  },
573
2818
  children: [
574
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
575
- style: { color: "var(--dsw-alias-label-tertiary, #999)" },
576
- children: expanded ? "▾" : "▸"
2819
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2820
+ variant: "ghost",
2821
+ size: "sm",
2822
+ 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 }),
2823
+ "aria-expanded": expanded,
2824
+ "aria-controls": panelId,
2825
+ "aria-label": toggleLabel,
2826
+ title: toggleLabel,
2827
+ onClick: (event) => {
2828
+ event.stopPropagation();
2829
+ setOpenReport(expanded ? void 0 : front.report);
2830
+ }
577
2831
  }),
578
2832
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
579
- style: { flex: "1 1 auto" },
2833
+ style: {
2834
+ flex: "1 1 auto",
2835
+ minWidth: 0
2836
+ },
580
2837
  children: [
581
2838
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
582
- style: chip(statusStyle(front.status)),
583
- children: front.status
2839
+ style: tagRow,
2840
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
2841
+ tone: STATUS_TONE[front.status],
2842
+ children: t(STATUS_LABEL[front.status])
2843
+ })
584
2844
  }),
585
2845
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: front.report }),
586
2846
  " ",
587
2847
  front.subject,
588
- front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
589
- type: "button",
2848
+ front.task === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Pill, {
590
2849
  title: fill(t("row.filterByTask"), { task: front.task }),
591
2850
  onClick: (event) => {
592
2851
  event.stopPropagation();
2852
+ setStatus("all");
593
2853
  setText(front.task);
594
2854
  },
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
2855
  children: front.task
605
2856
  }),
2857
+ wtLabel === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tag, {
2858
+ tone: LEASE_TONE[wtLane?.state ?? "none"],
2859
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2860
+ title: `${fill(t("row.filterByWorktree"), { worktree: wtLabel })} — ${t(LEASE_HINT[wtLane?.state ?? "none"])}`,
2861
+ style: { cursor: "pointer" },
2862
+ onClick: (event) => {
2863
+ event.stopPropagation();
2864
+ setStatus("all");
2865
+ setText(wtLabel);
2866
+ },
2867
+ children: wtLabel
2868
+ })
2869
+ }),
606
2870
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
607
2871
  style: {
608
2872
  ...time,
609
- marginTop: "2px"
2873
+ marginTop: "2px",
2874
+ overflow: "hidden",
2875
+ textOverflow: "ellipsis"
610
2876
  },
611
2877
  children: [
612
- `from=${front.from}`,
613
- front.to.length > 0 ? ` → ${front.to.join(", ")}` : "",
614
- front.cc.length > 0 ? ` cc ${front.cc.join(", ")}` : "",
2878
+ `from=`,
2879
+ idRef(front.from),
2880
+ 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,
2881
+ 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,
615
2882
  ` · ${fill(t("row.hops"), { count: front.hops })}`,
616
2883
  front.authors.length > 1 ? ` · ${fill(t("row.authors"), { count: front.authors.length })}` : "",
617
- front.last === void 0 ? "" : ` · last=${front.last.action}`
2884
+ front.last === void 0 ? "" : ` · ${t("row.last")} ${t(HOP_LABEL[front.last.action])}`
618
2885
  ]
619
2886
  })
620
2887
  ]
@@ -625,6 +2892,7 @@ window.__ModuleLoader__.load({
625
2892
  })
626
2893
  ]
627
2894
  }), expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
2895
+ id: panelId,
628
2896
  style: {
629
2897
  margin: "4px 0 10px 24px",
630
2898
  padding: "10px 12px",
@@ -661,40 +2929,53 @@ window.__ModuleLoader__.load({
661
2929
  style: time,
662
2930
  children: stamp(hop.at)
663
2931
  }),
664
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: hop.action }) }),
2932
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t(HOP_LABEL[hop.action]) }) }),
665
2933
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
666
- style: time,
667
- children: hop.actor
2934
+ style: wrap,
2935
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2936
+ label: idHint(hop.actor),
2937
+ side: "bottom",
2938
+ delayMs: 400,
2939
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: hop.actor })
2940
+ })
668
2941
  }),
669
2942
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
670
- style: time,
2943
+ style: wrap,
671
2944
  children: hop.to.length === 0 ? hop.note ?? "" : `→ ${hop.to.join(", ")}${hop.note === void 0 ? "" : ` (${hop.note})`}`
672
2945
  })
673
2946
  ] }, `h-${hopIndex}`))
674
2947
  }),
675
- detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
2948
+ detail.pending.length === 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
676
2949
  style: {
677
- ...time,
678
- color: "var(--dsw-alias-state-warn-label, #9a6700)"
2950
+ ...wrap,
2951
+ display: "flex",
2952
+ alignItems: "baseline",
2953
+ gap: "6px"
679
2954
  },
680
- children: fill(t("view.detail.pending"), { targets: detail.pending.join(", ") })
2955
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
2956
+ state: "warning",
2957
+ size: 8
2958
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: fill(t("view.detail.pending"), { targets: detail.pending.join(", ") }) })]
681
2959
  }),
682
2960
  (() => {
683
2961
  const parent = detail.front.parent;
684
2962
  const children = detail.front.children;
685
2963
  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
2964
+ const link = (target) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
2965
+ style: { marginRight: "6px" },
2966
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
2967
+ label: fill(t("detail.openLinked"), { report: target }),
2968
+ side: "bottom",
2969
+ delayMs: 400,
2970
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
2971
+ variant: "outline",
2972
+ size: "sm",
2973
+ onClick: () => {
2974
+ setOpenReport(target);
2975
+ },
2976
+ children: target
2977
+ })
2978
+ })
698
2979
  }, target);
699
2980
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
700
2981
  style: { marginBottom: "8px" },
@@ -719,15 +3000,14 @@ window.__ModuleLoader__.load({
719
3000
  })(),
720
3001
  inList.has(detail.front.report) ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
721
3002
  style: {
722
- ...time,
3003
+ ...wrap,
723
3004
  marginBottom: "8px"
724
3005
  },
725
3006
  children: [
726
3007
  fill(t("detail.outside"), { report: detail.front.report }),
727
3008
  " ",
728
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
729
- type: "button",
730
- style: button,
3009
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3010
+ variant: "outline",
731
3011
  onClick: () => {
732
3012
  setOpenReport(void 0);
733
3013
  },
@@ -737,7 +3017,7 @@ window.__ModuleLoader__.load({
737
3017
  }),
738
3018
  inList.has(detail.front.report) && !filteredReportIds.has(detail.front.report) ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
739
3019
  style: {
740
- ...time,
3020
+ ...wrap,
741
3021
  marginBottom: "8px"
742
3022
  },
743
3023
  children: fill(t("detail.filteredOut"), { report: detail.front.report })
@@ -746,38 +3026,49 @@ window.__ModuleLoader__.load({
746
3026
  style: { margin: "8px 0 4px" },
747
3027
  children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("view.detail.body") })
748
3028
  }),
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()
3029
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
3030
+ style: bodyFrame,
3031
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.MarkdownText, {
3032
+ text: bodyText,
3033
+ labels: markdownLabels
3034
+ })
763
3035
  }),
764
3036
  shownBody.truncated ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
765
3037
  style: {
766
- ...time,
3038
+ ...wrap,
767
3039
  marginTop: "4px"
768
3040
  },
769
3041
  children: fill(t("detail.bodyTruncated"), { chars: shownBody.text.trim().length })
770
3042
  }) : null,
771
3043
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
772
- style: {
773
- ...time,
774
- marginTop: "6px",
775
- wordBreak: "break-all"
776
- },
3044
+ style: pathRow,
777
3045
  children: [
778
- t("view.detail.path"),
779
- ": ",
780
- detail.path
3046
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.StateDot, {
3047
+ state: "idle",
3048
+ size: 6
3049
+ }),
3050
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
3051
+ style: { wordBreak: "break-all" },
3052
+ children: [
3053
+ t("view.detail.path"),
3054
+ ": ",
3055
+ detail.path
3056
+ ]
3057
+ }),
3058
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Tooltip, {
3059
+ label: copiedPath ? t("body.copied") : t("view.copyPath"),
3060
+ side: "bottom",
3061
+ delayMs: 300,
3062
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(_deepseek_ai_dsh_client_ui_primitives.Button, {
3063
+ variant: "ghost",
3064
+ size: "sm",
3065
+ 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 }),
3066
+ "aria-label": copiedPath ? t("body.copied") : t("view.copyPath"),
3067
+ onClick: () => {
3068
+ copyPath(detail.path);
3069
+ }
3070
+ })
3071
+ })
781
3072
  ]
782
3073
  })
783
3074
  ] })
@@ -789,101 +3080,6 @@ window.__ModuleLoader__.load({
789
3080
  });
790
3081
  }
791
3082
  //#endregion
792
- //#region src/client/locales.ts
793
- /**
794
- * Dictionary for the report timeline tab.
795
- *
796
- * Both shipped locales are required by the client locale service, and the key
797
- * union drives the typed `t()` the view uses, so a missing translation is a
798
- * type error rather than an empty label.
799
- *
800
- * @module dsh-report-ledger/client/locales
801
- */
802
- /** Chinese dictionary — the source of truth for the key set. */
803
- const zh = {
804
- "view.tab": "汇报",
805
- "view.loading": "正在读取账本…",
806
- "view.summary": "{sessions} 个会话 · {reports} 份汇报",
807
- "view.empty": "这个会话树下还没有汇报。代理用 report_author 开一份汇报后,这里会出现它的缩略卡。",
808
- "view.refresh": "刷新",
809
- "view.retry": "重试",
810
- "view.error": "读取失败:{code}",
811
- "view.root": "本会话",
812
- "view.live": "在线",
813
- "view.delegated": "子代理",
814
- "view.detail.loading": "正在读取传递路径…",
815
- "view.detail.route": "传递路径",
816
- "view.detail.routeEmpty": "(还没有记录跳)",
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} 字;完整内容见账本文件。"
843
- };
844
- /** English dictionary, checked against the Chinese key set. */
845
- const en = {
846
- "view.tab": "Reports",
847
- "view.loading": "Reading the ledger…",
848
- "view.summary": "{sessions} sessions · {reports} reports",
849
- "view.empty": "No reports in this session tree yet. Once an agent opens one with report_author, its digest card appears here.",
850
- "view.refresh": "Refresh",
851
- "view.retry": "Retry",
852
- "view.error": "Read failed: {code}",
853
- "view.root": "this session",
854
- "view.live": "live",
855
- "view.delegated": "subagent",
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."
885
- };
886
- //#endregion
887
3083
  //#region src/client/index.ts
888
3084
  /**
889
3085
  * Browser half of dsh-report-ledger — runs inside the dsh web GUI.
@@ -917,6 +3113,7 @@ window.__ModuleLoader__.load({
917
3113
  zh,
918
3114
  en
919
3115
  }), "report-ledger: dictionaries");
3116
+ ctx.effect(() => installStyles(), "report-ledger: adapter styles");
920
3117
  const t = ctx.locale.bind(NS);
921
3118
  ctx.slots.inject("conversation.view", () => ctx.slots.register({
922
3119
  name: "conversation.view",