dsh-git-badge 0.19.0 → 0.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -1,28 +1,24 @@
1
1
  /**
2
2
  * dsh-git-badge — client half.
3
3
  *
4
- * Both surfaces show the SAME status mark — a circle, or a tree for a linked
5
- * worktree, filled with the status colour — so status and worktree-ness read
6
- * identically wherever they appear.
4
+ * Every surface rides an UPSTREAM seat. No patch, no manual step.
7
5
  *
8
6
  * Surfaces:
9
- * - sidebar.workspaces.sessionRow (seam): session-row badge — the status mark
10
- * and the PR/CI token, immediately after the session title. Deliberately shows
11
- * NO branch and no worktree name: the row lists conversations, and which
12
- * checkout the badge describes is the hover card's business.
13
- * - sidebar.session.row.hover (upstream, no patch): the hover-card line that
14
- * names that checkout, rendered inside the card upstream ALREADY shows for a
7
+ * - sidebar.session.row.leading: the session row's token as a single glyph in
8
+ * the row's 16px leading cell, from the node half's `rowToken`. Upstream
9
+ * renders this seat only while the session is IDLE.
10
+ * - sidebar.session.row.hover: the hover card that explains the token and
11
+ * names the checkout, rendered inside the card upstream ALREADY shows for a
15
12
  * session row rather than in a tooltip nested inside it.
16
- * - conversation.input.left (upstream): chip — status mark + branch + a
13
+ * - conversation.input.left: chip — status mark + branch + a
17
14
  * worktree's name + in-progress operation token + sync/dirty suffix + the
18
15
  * PR/CI token, with a HOVER CARD carrying the breakdown the chip has no room
19
16
  * for (file detail, recent commits, stash, PR state, and the conversation's own
20
17
  * checkout when the badge followed a worktree).
21
18
  *
22
19
  * The chip's hover card needs nothing but the upstream slot: the Tooltip
23
- * primitive is seeded by the shell itself, so the card works on a PRISTINE
24
- * install with no seam patch. Its extra fields are fetched only once a pointer
25
- * rests on the chip, so the everyday badge pays for none of them.
20
+ * primitive is seeded by the shell itself. Its extra fields are fetched only once
21
+ * a pointer rests on the chip, so the everyday badge pays for none of them.
26
22
  */
27
23
  window.__ModuleLoader__.load({
28
24
  id: "dsh-git-badge",
@@ -50,11 +46,6 @@ window.__ModuleLoader__.load({
50
46
  primitives = null;
51
47
  }
52
48
  const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
53
- // The shell's pull-down primitive (what the input-area selectors use) and
54
- // its candidate ranker. Same guarded require as Tooltip: a shell without
55
- // them loses the pull-down affordances, never the badges.
56
- const Menu = primitives === null ? void 0 : primitives.Menu;
57
- const rankByName = primitives === null ? void 0 : primitives.rankByName;
58
49
 
59
50
  /**
60
51
  * Module-level git-status cache. Entries are invalidated by SSE events,
@@ -75,16 +66,14 @@ window.__ModuleLoader__.load({
75
66
  /**
76
67
  * A request target names WHO is asking, never a filesystem path — the node
77
68
  * half resolves the directory itself, so a surface never needs to know
78
- * one. The chip targets its conversation (`session`); a sidebar row
79
- * targets the workspace id the seam hands it. The query string doubles as
80
- * the cache key.
69
+ * one. Every surface here targets its conversation (`session`). The query
70
+ * string doubles as the cache key.
81
71
  *
82
- * The two optional additions are the INPUT CHIP's alone, and each is an
83
- * explicit request rather than something every badge pays for:
84
- * - `detail=1` — recent commits and stash for the hover card;
85
- * - `pr=1` — the branch's GitHub PR/CI state.
86
- * A sidebar row asks for neither, so surveying N workspaces never costs N
87
- * forge calls (see the node half's PR region).
72
+ * The two optional additions, each an explicit request:
73
+ * - `detail=1` — recent commits and stash for the chip's hover card;
74
+ * - `pr=1` — the branch's GitHub PR/CI state, which both the chip and
75
+ * the row token need (the leading glyph and the hover card share this
76
+ * exact key, so they cost one request between them).
88
77
  */
89
78
  function targetQuery(target, options) {
90
79
  if (target === void 0 || target.id === void 0) return void 0;
@@ -98,11 +87,9 @@ window.__ModuleLoader__.load({
98
87
  //#region SSE change feed (one EventSource per page, ref-counted)
99
88
  const eventListeners = new Set();
100
89
  let eventSource = null;
101
- let eventRefs = 0;
102
90
 
103
91
  function subscribeGitEvents(onChange) {
104
92
  eventListeners.add(onChange);
105
- eventRefs += 1;
106
93
  if (eventSource === null) {
107
94
  eventSource = new EventSource("/api/git-badge/events");
108
95
  // NAMED frames: the node half writes `event: change`, and onmessage
@@ -129,8 +116,7 @@ window.__ModuleLoader__.load({
129
116
  }
130
117
  return () => {
131
118
  eventListeners.delete(onChange);
132
- eventRefs -= 1;
133
- if (eventRefs === 0 && eventSource !== null) {
119
+ if (eventListeners.size === 0 && eventSource !== null) {
134
120
  eventSource.close();
135
121
  eventSource = null;
136
122
  }
@@ -181,7 +167,7 @@ window.__ModuleLoader__.load({
181
167
  // event or the fallback poll succeeds.
182
168
  if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
183
169
  if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
184
- GIT_CACHE.set(cacheKey, { at: Date.now(), data });
170
+ GIT_CACHE.set(cacheKey, { data });
185
171
  if (GIT_CACHE.size > GIT_CACHE_LIMIT) {
186
172
  GIT_CACHE.delete(GIT_CACHE.keys().next().value);
187
173
  }
@@ -217,32 +203,6 @@ window.__ModuleLoader__.load({
217
203
  return state.key === cacheKey ? state.data : void 0;
218
204
  }
219
205
 
220
- /**
221
- * Three-state status, checked top-down, first match wins:
222
- * - "error": unmerged files (mid-conflict, git is blocked) — or a dirty
223
- * tree that is ALSO behind upstream (unsaved edits on an outdated
224
- * base: commit-then-pull friction ahead).
225
- * - "warn": dirty files, or any ahead/behind (routine work or sync
226
- * pending — including the previously "quiet" green-but-↓1 case).
227
- * - "ok": clean and in sync.
228
- * Red never means merely "behind": a clean tree one commit behind is
229
- * normal between pulls, not an alarm.
230
- *
231
- * The single source of truth for that summary. BOTH surfaces render it the
232
- * same way (see StatusMark): a circle, or a tree in a linked worktree,
233
- * filled with the colour below.
234
- */
235
- function badgeStatus(info) {
236
- const dirty = info.dirty === true;
237
- const behind = (info.behind || 0) > 0;
238
- const ahead = (info.ahead || 0) > 0;
239
- const conflict = (info.unmergedFiles || 0) > 0;
240
- if (conflict || (dirty && behind)) return "error";
241
- if (dirty || ahead || behind) return "warn";
242
- return "ok";
243
- }
244
-
245
-
246
206
  /**
247
207
  * The actions BOTH surfaces render — the server's list, verbatim.
248
208
  *
@@ -278,49 +238,32 @@ window.__ModuleLoader__.load({
278
238
  }
279
239
 
280
240
  /**
281
- * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
282
- * follow light/dark and custom themes — which a hardcoded emoji cannot do.
283
- * The literals are only fallbacks for use outside DSH.
284
- */
285
- const MARK_FILL = {
286
- error: "var(--dsw-alias-state-error-primary, #e5484d)",
287
- warn: "var(--dsw-alias-state-warn-primary, #d29922)",
288
- ok: "var(--dsw-alias-state-success-primary, #30a46c)"
289
- };
290
-
291
- /**
292
- * Accessible name per status, WITHOUT the "git" prefix — the mark adds
293
- * "git: " or "git worktree: ", so shape and colour are never the only
294
- * channel for either fact.
295
- */
296
- const STATUS_LABEL = {
297
- error: "conflict, or uncommitted changes on an outdated base",
298
- warn: "uncommitted changes, or out of sync with upstream",
299
- ok: "clean and in sync"
300
- };
301
-
302
- /**
303
- * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row shows status alone;
241
+ * Base/dirty suffix — INPUT CHIP ONLY. The sidebar row shows status alone;
304
242
  * the chip is the surface that carries the numbers. Rule set:
305
243
  * - dirty files render as ✎n (pencil = worktree files, distinct from
306
- * the ↑/↓ commit-sync axis)
307
- * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
244
+ * the ↑/↓ commit axis)
245
+ * - while dirty, BOTH counts render including zeros (↑0 ↓2 ✎3),
308
246
  * so every number is positionally attributable
309
247
  * - clean workspaces stay quiet: arrows only when nonzero, no ✎ at 0
310
- * - no upstream: arrows omitted entirely
248
+ * - no base known: arrows omitted entirely
311
249
  */
250
+ /** The base branch's name, for the arrows and the card's base row. */
251
+ function baseNameFor(info) {
252
+ return typeof info?.mainBranch === "string" && info.mainBranch !== "" ? info.mainBranch : "main";
253
+ }
254
+
312
255
  /**
313
- * The upstream sync arrows (" ↑1 ↓2"), or "". Split out from the file
314
- * count so each rides the surface that OWNS it: the arrows belong to the
315
- * branch (they are the branch's standing against its upstream, and the
316
- * branch name's hover and compare link cover them), while ✎n belongs to the
317
- * count's names tooltip.
256
+ * The BASE ahead/behind arrows and the base's name (" ↑4 ↓0 main"), the
257
+ * branch's standing against the branch it will merge into. Naming the base
258
+ * stops the arrows from reading as the git-native upstream count. The
259
+ * branch's own remote sync is not a number here: the action glyph (`push`,
260
+ * `pull`) carries the actionable part.
318
261
  */
319
- function formatSync(info) {
320
- const hasUpstream = info.ahead !== void 0 || info.behind !== void 0;
262
+ function formatBaseSync(info) {
263
+ const hasBase = info.mainAhead !== void 0 || info.mainBehind !== void 0;
321
264
  const files = (info.changedFiles || 0) + (info.untrackedFiles || 0);
322
- if (!hasUpstream || (files === 0 && !(info.ahead > 0) && !(info.behind > 0))) return "";
323
- return " \u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0);
265
+ if (!hasBase || (files === 0 && !(info.mainAhead > 0) && !(info.mainBehind > 0))) return "";
266
+ return " \u2191" + (info.mainAhead || 0) + " \u2193" + (info.mainBehind || 0) + " " + baseNameFor(info);
324
267
  }
325
268
 
326
269
  /** The ✎n file count (" ✎3"), or "". */
@@ -329,11 +272,6 @@ window.__ModuleLoader__.load({
329
272
  return files > 0 ? " \u270E" + files : "";
330
273
  }
331
274
 
332
- /** Both halves, in chip order — kept for callers that want the whole suffix. */
333
- function formatGitSuffix(info) {
334
- return formatSync(info) + formatFileCount(info);
335
- }
336
-
337
275
  /**
338
276
  * PR / CI token — INPUT CHIP ONLY, and absent entirely when the node half
339
277
  * has nothing to say (no `gh`, no GitHub remote, no PR for the branch), so
@@ -344,21 +282,41 @@ window.__ModuleLoader__.load({
344
282
  */
345
283
  const PR_STATE_GLYPH = { passing: "\u2713", pending: "\u2026", failing: "\u2717", none: "" };
346
284
  const PR_STATE_LABEL = { passing: "checks passing", pending: "checks running", failing: "checks failing", none: "no checks" };
285
+ /** The PR's state label, defaulting to "no checks". */
286
+ function prStateLabel(pr) {
287
+ return PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
288
+ }
347
289
 
348
- /** ` PR#142 ✗` (plus ` draft`), or "" when there is no PR to report. */
349
- function formatPrToken(info) {
290
+ /**
291
+ * The CI state -> colour, for the token's STATE GLYPH only. The number and
292
+ * the link keep the chip's own muted colour, so the token still reads as one
293
+ * row and only the state shouts. These are the same app state tokens the
294
+ * action glyph uses.
295
+ */
296
+ const PR_STATE_COLOUR = {
297
+ passing: "var(--dsw-alias-state-success-primary, #30a46c)",
298
+ pending: "var(--dsw-alias-state-warn-primary, #d29922)",
299
+ failing: "var(--dsw-alias-state-error-primary, #e5484d)"
300
+ };
301
+
302
+ /** The PR token's parts, so its plain text and its coloured form cannot drift. */
303
+ function prTokenParts(info) {
350
304
  const pr = info.pr;
351
- if (pr === void 0 || pr === null || pr.number === void 0) return "";
305
+ if (pr === void 0 || pr === null || pr.number === void 0) return null;
352
306
  const state = PR_STATE_GLYPH[pr.state] === void 0 ? "none" : pr.state;
353
- const glyph = PR_STATE_GLYPH[state];
354
- return " PR#" + pr.number + (glyph === "" ? "" : " " + glyph) + (pr.draft === true ? " draft" : "");
307
+ return {
308
+ number: " PR#" + pr.number,
309
+ glyph: PR_STATE_GLYPH[state],
310
+ colour: PR_STATE_COLOUR[state] ?? META_STYLE.color,
311
+ draft: pr.draft === true ? " draft" : ""
312
+ };
355
313
  }
356
314
 
357
315
  /** The token's accessible name: "pull request 142, checks failing, draft". */
358
316
  function prTokenLabel(info) {
359
317
  const pr = info.pr;
360
318
  if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
361
- const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
319
+ const state = prStateLabel(pr);
362
320
  return "pull request " + pr.number + ", " + state + (pr.draft === true ? ", draft" : "");
363
321
  }
364
322
 
@@ -424,90 +382,6 @@ window.__ModuleLoader__.load({
424
382
  whiteSpace: "nowrap"
425
383
  };
426
384
 
427
- /**
428
- * Status mark, SHARED BY BOTH SURFACES: the same shape in the same fill
429
- * wherever it appears, so the sidebar row and the input chip read
430
- * identically. The FILL is the status colour; the SHAPE carries
431
- * worktree-ness — a circle for a main checkout, a tree for a linked
432
- * worktree — so the two facts never compete for the same channel.
433
- *
434
- * Drawn rather than typed because 🌳 is a COLOUR EMOJI: CSS cannot tint
435
- * its leaves, and a mark that carries the status has to be paintable.
436
- * Three overlapping crown lobes plus a trunk stay legible at this size.
437
- *
438
- * Neither colour nor shape is the only channel: the accessible name
439
- * states both.
440
- */
441
- function StatusMark({ info }) {
442
- const status = badgeStatus(info);
443
- const fill = MARK_FILL[status];
444
- const isWorktree = info.isWorktree === true;
445
- const svg = {
446
- // 14px, NOT 12px: a colour emoji draws well above its nominal size — the
447
- // 🔴/🟡/🟢 dot this replaces put down ~13-14px of ink at the surfaces'
448
- // 12px font-size, so a 12px box read as a shrunken dot.
449
- width: 14,
450
- height: 14,
451
- viewBox: "0 0 12 12",
452
- role: "img",
453
- "aria-label":
454
- (isWorktree ? "git worktree: " : "git: ") +
455
- STATUS_LABEL[status] +
456
- // a followed checkout is not where the conversation lives, and the
457
- // accessible name says so — the session registered it explicitly
458
- (info.worktreeFollowed === true ? " (registered for this session)" : ""),
459
- focusable: "false",
460
- style: { flex: "none", display: "block", color: META_STYLE.color }
461
- };
462
- if (!isWorktree) {
463
- // a plain filled disc: this is a main checkout. It fills its box so the
464
- // ink lands at the size the emoji dot used to.
465
- return react_jsx_runtime.jsx("svg", { ...svg, children: react_jsx_runtime.jsx("circle", { cx: 6, cy: 6, r: 5.5, fill }) });
466
- }
467
- return react_jsx_runtime.jsxs("svg", {
468
- ...svg,
469
- children: [
470
- // trunk first, so the crown's lobes overlap and join it. These are the
471
- // 12px-era coordinates scaled 1.15 about the tree's centre, so its ink
472
- // stays comparable to the disc's instead of reading small beside it.
473
- react_jsx_runtime.jsx("rect", { key: "trunk", x: 5.25, y: 7.3, width: 1.5, height: 4.14, rx: 0.63, fill: "currentColor" }),
474
- react_jsx_runtime.jsx("circle", { key: "crown-1", cx: 6, cy: 3.62, r: 3.57, fill }),
475
- react_jsx_runtime.jsx("circle", { key: "crown-2", cx: 3.24, cy: 5.46, r: 2.53, fill }),
476
- react_jsx_runtime.jsx("circle", { key: "crown-3", cx: 8.76, cy: 5.46, r: 2.53, fill })
477
- ]
478
- });
479
- }
480
-
481
- /**
482
- * The ONE thing this conversation needs from you, or "" when it needs
483
- * nothing.
484
- *
485
- * A session row's job is TRIAGE — "does this need me?" — so this is not the
486
- * chip's status restated: it is a short imperative, present only when an
487
- * action is genuinely warranted, first match wins. Silence is the common and
488
- * correct answer: uncommitted work is a state rather than a chore, and DRAFT,
489
- * BLOCKED, BEHIND, UNSTABLE and UNKNOWN are things to wait for, not to do.
490
- *
491
- * `merge` keys on GitHub's OWN verdict (`mergeStateStatus === "CLEAN"`) rather
492
- * than a checks-plus-reviews judgement assembled here: mergeability depends on
493
- * branch protection and required reviews, which is the forge's business. A PR
494
- * read that could not answer has no `mergeState`, so it stays silent.
495
- */
496
- function actionToken(info) {
497
- if (info === void 0 || info === null || info.git !== true) return "";
498
- if ((info.unmergedFiles || 0) > 0) return "resolve";
499
- const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
500
- if (pr !== void 0) {
501
- if (pr.state === "failing") return "fix CI";
502
- if (pr.review === "CHANGES_REQUESTED") return "review";
503
- if (pr.mergeState === "CLEAN") return "merge";
504
- }
505
- // diverged counts as behind: reconciling is the action either way
506
- if ((info.behind || 0) > 0) return "pull";
507
- if ((info.ahead || 0) > 0) return "push";
508
- return "";
509
- }
510
-
511
385
  /**
512
386
  * Session-row badge — the ACTION, and nothing else:
513
387
  * Session title… merge
@@ -534,70 +408,132 @@ window.__ModuleLoader__.load({
534
408
  * below reuses THIS request rather than issuing its own.
535
409
  */
536
410
  /**
537
- * Action -> colour. Colour here means SEVERITY — how much this needs you —
538
- * rather than identity: a broken build and a conflict are the loud ones, a
539
- * requested review is a nudge, `merge` is the all-clear, and routine sync is
540
- * the quiet end of the same ramp rather than outside it. That last move is
541
- * deliberate: grey put `push` below the threshold of notice, so the one row
542
- * that wanted a decision read as a timestamp. The WORD remains the channel
543
- * (colour only reinforces it), and the tokens are the app's own, so both
544
- * themes work.
411
+ * Severity -> colour. The NODE half decides the severity
412
+ * (`error` | `warn` | `success` | `business`) and every surface paints from
413
+ * that one field, so the chip and the row cannot disagree about how loud a
414
+ * state is. Colour here means SEVERITY — how much this needs you — rather
415
+ * than identity: a broken build and a conflict are the loud ones, a requested
416
+ * review is a nudge, `merge` is the all-clear, and routine work is the quiet
417
+ * blue end of the same ramp rather than outside it. The WORD remains the
418
+ * channel (colour only reinforces it), and the tokens are the app's own, so
419
+ * both themes work.
545
420
  */
546
- const ACTION_COLOUR = {
547
- resolve: "var(--dsw-alias-state-error-primary, #e5484d)",
548
- "fix CI": "var(--dsw-alias-state-error-primary, #e5484d)",
549
- review: "var(--dsw-alias-state-warn-primary, #d29922)",
550
- merge: "var(--dsw-alias-state-success-primary, #30a46c)",
551
- pull: "var(--dsw-alias-state-business-primary, #3b82f6)",
552
- push: "var(--dsw-alias-state-business-primary, #3b82f6)"
421
+ const SEVERITY_FILL = {
422
+ error: "var(--dsw-alias-state-error-primary, #e5484d)",
423
+ warn: "var(--dsw-alias-state-warn-primary, #d29922)",
424
+ success: "var(--dsw-alias-state-success-primary, #30a46c)",
425
+ business: "var(--dsw-alias-state-business-primary, #3b82f6)"
553
426
  };
554
427
 
555
428
  /**
556
- * Action -> glyph. The SHAPE channel, beside the colour: a glyph makes the
557
- * row readable before the word is parsed, and it survives a monochrome or
558
- * colour-blind view where the tint alone would not. Keys mirror
559
- * ACTION_COLOUR exactly; a missing entry degrades to the bare word.
429
+ * Action -> TEXT glyph. The SHAPE channel for the five tokens whose icon is a
430
+ * character. The other six are DRAWN (see DRAWN_ICONS) and deliberately have
431
+ * NO entry here: a leftover text fallback would be a second, unreachable
432
+ * rendering of the same icon — and for `merge` it was exactly the check mark
433
+ * the drawn Octicon replaced.
434
+ *
435
+ * A missing entry degrades to the bare word.
560
436
  */
561
437
  const ACTION_GLYPH = {
438
+ resume: "\u23F5",
562
439
  resolve: "!",
563
440
  "fix CI": "\u2717",
564
441
  review: "\u25C6",
565
- merge: "\u2713",
566
- pull: "\u2193",
567
- push: "\u2191"
442
+ commit: "\u271A"
568
443
  };
569
444
 
570
445
  /**
571
- * The action's own style, on top of META_STYLE:
572
- * - `marginLeft: auto` floats it right, so a column of actions lines up down
573
- * the sidebar — that alignment is what makes the list scannable;
574
- * - `marginRight` keeps it clear of the relative time beside it (the first cut
575
- * sat flush and read `merge11m`);
576
- * - weight 600 plus a little padding make it a PILL, so the token reads as a
577
- * tag rather than as loose text the eye can skip;
578
- * - the radius is the app's own small one, so the pill belongs to the shell.
446
+ * The icons DRAWN rather than typed, each with its own grid and render size.
579
447
  *
580
- * The FILL is not set here: it is the action's own colour at low alpha, which
581
- * only SessionGitBadge can build.
448
+ * `↑ ↓ ⇄ ↻` are hairline strokes in the shell's font, and no weight or size
449
+ * makes them as solid as the filled glyphs; these four are paths on a 12x12
450
+ * grid, painted with `currentColor`, so each arrow matches the text's weight
451
+ * and centres on it. `in sync` is drawn for the same reason: the idle disc
452
+ * must be exactly round, not a font's idea of a circle.
453
+ *
454
+ * `merge` is Remix Icon's `git-merge-line` (24x24), vendored VERBATIM rather
455
+ * than redrawn, and rendered at 16px to fill the row's 16px leading cell.
456
+ * Unicode has no merge glyph at all, so every tool draws this shape family.
457
+ * Its ink is inset (about 16 of its 24 units), so at 16px it puts down
458
+ * ~10.7px — the arrows' weight, which a 12px render would miss.
459
+ *
460
+ * The other five tokens stay text.
582
461
  */
583
- const ACTION_STYLE = {
584
- flex: "none",
585
- marginLeft: "auto",
586
- marginRight: "8px",
587
- fontWeight: 600,
588
- padding: "0 6px",
589
- borderRadius: "var(--dsw-radius-sm, 4px)"
462
+ const DRAWN_ICONS = {
463
+ push: { size: 12, viewBox: "0 0 12 12", shapes: [{ d: "M6 1 L10.6 6.6 H7.5 V11 H4.5 V6.6 H1.4 Z" }] },
464
+ pull: { size: 12, viewBox: "0 0 12 12", shapes: [{ d: "M6 11 L1.4 5.4 H4.5 V1 H7.5 V5.4 H10.6 Z" }] },
465
+ sync: {
466
+ size: 12,
467
+ viewBox: "0 0 12 12",
468
+ shapes: [
469
+ { d: "M1.5 3.4 H8.4 V2 L11 4 L8.4 6 V4.6 H1.5 Z" },
470
+ { d: "M10.5 8.6 H3.6 V10 L1 8 L3.6 6 V7.4 H10.5 Z" }
471
+ ]
472
+ },
473
+ update: {
474
+ size: 12,
475
+ viewBox: "0 0 12 12",
476
+ shapes: [
477
+ { d: "M6 1.9 A4.1 4.1 0 1 1 1.9 6", stroke: true },
478
+ { d: "M4.3 0.4 L8.3 1.9 L4.3 3.4 Z" }
479
+ ]
480
+ },
481
+ "in sync": { size: 12, viewBox: "0 0 12 12", shapes: [{ circle: true }] },
482
+ // Remix Icon `git-merge-line` (24x24), vendored verbatim from
483
+ // https://github.com/Remix-Design/RemixIcon/blob/master/icons/Development/git-merge-line.svg
484
+ // EXP-1: this is an experiment. Swapping back to GitHub's Octicon
485
+ // `git-merge` is this one entry — a 16x16 grid, same shape family.
486
+ merge: {
487
+ size: 16,
488
+ viewBox: "0 0 24 24",
489
+ shapes: [{ d: "M7.10508 8.78991C7.45179 10.0635 8.61653 11 10 11H14C16.4703 11 18.5222 12.7915 18.9274 15.1461C20.1303 15.5367 21 16.6668 21 18C21 19.6569 19.6569 21 18 21C16.3431 21 15 19.6569 15 18C15 16.7334 15.7849 15.6501 16.8949 15.2101C16.5482 13.9365 15.3835 13 14 13H10C8.87439 13 7.83566 12.6281 7 12.0004V15.1707C8.16519 15.5825 9 16.6938 9 18C9 19.6569 7.65685 21 6 21C4.34315 21 3 19.6569 3 18C3 16.6938 3.83481 15.5825 5 15.1707V8.82929C3.83481 8.41746 3 7.30622 3 6C3 4.34315 4.34315 3 6 3C7.65685 3 9 4.34315 9 6C9 7.26661 8.21506 8.34988 7.10508 8.78991ZM6 7C6.55228 7 7 6.55228 7 6C7 5.44772 6.55228 5 6 5C5.44772 5 5 5.44772 5 6C5 6.55228 5.44772 7 6 7ZM6 19C6.55228 19 7 18.5523 7 18C7 17.4477 6.55228 17 6 17C5.44772 17 5 17.4477 5 18C5 18.5523 5.44772 19 6 19ZM18 19C18.5523 19 19 18.5523 19 18C19 17.4477 18.5523 17 18 17C17.4477 17 17 17.4477 17 18C17 18.5523 17.4477 19 18 19Z" }]
490
+ }
590
491
  };
591
492
 
493
+ /**
494
+ * The icon NODE: a drawn shape for the six drawn tokens, a text glyph for the
495
+ * rest. The parent span carries the accessible name, so the drawing is
496
+ * decoration (`aria-hidden`).
497
+ */
498
+ function actionGlyphNode(token) {
499
+ const icon = DRAWN_ICONS[token];
500
+ if (icon === void 0) return ACTION_GLYPH[token] ?? token;
501
+ return react_jsx_runtime.jsx("svg", {
502
+ viewBox: icon.viewBox,
503
+ width: icon.size,
504
+ height: icon.size,
505
+ "aria-hidden": true,
506
+ focusable: "false",
507
+ style: { display: "block", flex: "none", color: "inherit" },
508
+ children: icon.shapes.map((shape, index) => shape.circle === true
509
+ ? react_jsx_runtime.jsx("circle", {
510
+ cx: shape.cx ?? 6,
511
+ cy: shape.cy ?? 6,
512
+ r: shape.r ?? 5,
513
+ fill: "currentColor"
514
+ }, "shape-" + index)
515
+ : react_jsx_runtime.jsx("path", shape.stroke === true
516
+ ? { d: shape.d, fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }
517
+ : { d: shape.d, fill: "currentColor" }, "shape-" + index))
518
+ });
519
+ }
520
+
521
+ /**
522
+ * The icon's own type style, for the TEXT glyphs only. A drawn arrow is a
523
+ * fixed 12px box, so size and weight do not apply to it; the six text glyphs
524
+ * sit one pixel larger than the surrounding 12px text.
525
+ */
526
+ const GLYPH_TEXT = { fontSize: "13px" };
527
+
592
528
  /**
593
529
  * The row's own run state, from the shell's standard kit.
594
530
  *
595
531
  * The renderer that assembles slots hands EVERY entry the sessions store as
596
532
  * a selector hook (`useSessions`) — the same kit that supplies
597
533
  * `useWorkspaces` — which is how a row can tell an idle session from one
598
- * mid-turn without the seam having to carry it. A shell that does not seed
599
- * that kit leaves the prop undefined: then we cannot know, and the badge
600
- * behaves exactly as it did before rather than guessing "idle".
534
+ * mid-turn without the row slot having to carry it. A shell that does not
535
+ * seed that kit leaves the prop undefined: then we cannot know, and the
536
+ * glyph behaves exactly as it did before rather than guessing "idle".
601
537
  *
602
538
  * One helper and one call site, so the hook order is identical on every path
603
539
  * (the fallback is a plain function, never a hook).
@@ -608,8 +544,8 @@ window.__ModuleLoader__.load({
608
544
  }
609
545
 
610
546
  /**
611
- * Report the idle gate ONCE, the way the boot diagnostics report the seam and
612
- * the Tooltip primitive. Whether the shell actually handed a slot the
547
+ * Report the idle gate ONCE, the way the boot diagnostics report the
548
+ * Tooltip primitive. Whether the shell actually handed a slot the
613
549
  * sessions store is invisible from anywhere else, and a gate that silently
614
550
  * never fires is indistinguishable from a gate that never needed to.
615
551
  */
@@ -626,60 +562,357 @@ window.__ModuleLoader__.load({
626
562
  }
627
563
 
628
564
  /**
629
- * The action badge: a severity-tinted pill carrying a glyph and the word — and
630
- * shown only when the session is IDLE. A turn in flight is precisely when a
631
- * git suggestion is noise: the checkout is still moving and the agent has not
632
- * finished, so the action describes a state that is about to change. Nothing
633
- * about the action itself changes; the gate is the whole feature.
565
+ * The leading-cell token glyph — the row's compact signal, on upstream's
566
+ * own `sidebar.session.row.leading` seat. No patch and no seam.
634
567
  *
635
- * The filter is on RENDER, not on the fetch: hiding the row must not take the
636
- * hover card's detail line down with it — both read the same cached status.
568
+ * It asks for PR state too (`pr: true`), the SAME query the row card uses,
569
+ * so the glyph shows the same PR tokens (`merge`, `review`, `fix CI`) as
570
+ * the card — not only the local ones. The matching query string means the
571
+ * glyph and the card share one cache entry and one in-flight read. The
572
+ * price is one forge read per row per TTL.
573
+ *
574
+ * Geometry: the cell is `width:16px; flex:none` and the row has `gap:0`, so
575
+ * a word would overflow into the title. Only one glyph is drawn, and no
576
+ * wrapper changes the cell's box.
577
+ *
578
+ * The glyph is NOT `aria-hidden`: with no word beside it, it is the
579
+ * accessible name. With nothing to say the cell renders NOTHING: no token
580
+ * means no action, so there is no icon and no colour to explain.
581
+ *
582
+ * It carries NO hover of its own. The row is already a HoverCard anchor,
583
+ * and the explanation rides the row's own card (SessionGitDetail) — so
584
+ * hovering the glyph shows exactly what hovering the session line shows.
637
585
  */
638
- function SessionGitBadge({ sessionId, useSessions }) {
586
+ function LeadingTokenGlyph({ sessionId, useSessions }) {
639
587
  const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
640
588
  const running = useSessionRunning(useSessions, sessionId);
641
589
  reportIdleGate(typeof useSessions === "function");
642
590
  if (running) return null;
643
591
  if (sessionId === void 0 || info === void 0 || info.git !== true) return null;
644
- const action = actionToken(info);
645
- if (action === "") return null;
646
- const colour = ACTION_COLOUR[action];
647
- const style = { ...META_STYLE, ...ACTION_STYLE };
648
- if (colour !== void 0) {
649
- style.color = colour;
650
- // The pill's fill is the action's own colour at low alpha, so severity is
651
- // a wash a row can carry rather than a shout. color-mix is a current-browser
652
- // feature: where it is unsupported the pill keeps its geometry, colour,
653
- // glyph and weight, and only the tint is lost.
654
- style.background = `color-mix(in srgb, ${colour} 18%, transparent)`;
592
+ const token = info.rowToken;
593
+ if (typeof token === "string" && token !== "") {
594
+ const colour = SEVERITY_FILL[info.rowSeverity];
595
+ return react_jsx_runtime.jsx("span", {
596
+ role: "img",
597
+ "aria-label": "git action: " + token,
598
+ // The fallback overlay in the SAME cell is suppressed by this
599
+ // marker, so a status transition can never paint two glyphs.
600
+ "data-git-badge-leading": "",
601
+ style: { ...META_STYLE, ...(colour === void 0 ? {} : { color: colour }), ...GLYPH_TEXT, display: "inline-flex", alignItems: "center" },
602
+ children: actionGlyphNode(token)
603
+ });
655
604
  }
656
- // The glyph is DECORATION: `aria-hidden` keeps the accessible name the bare
657
- // word, and the trailing space in its own text separates the two on screen
658
- // without a margin. A missing glyph entry degrades to the word alone.
659
- const glyph = ACTION_GLYPH[action];
660
- return react_jsx_runtime.jsxs("span", {
661
- style,
662
- children:
663
- glyph === void 0
664
- ? action
665
- : [react_jsx_runtime.jsx("span", { "aria-hidden": true, children: `${glyph} ` }, "glyph"), action]
666
- });
605
+ return null;
667
606
  }
668
607
 
608
+ //#region leading-cell fallback (a solid status dot owns the cell)
609
+ /**
610
+ * The leading cell's SOLID status dots. Upstream replaces the whole
611
+ * `sidebar.session.row.leading` seat with one of these whenever the row's
612
+ * primary state is not idle, so the plugin's glyph is not mounted at all:
613
+ * `done` (green) when a background turn finished unread, `warning` (orange)
614
+ * when a pending interaction waits. `ongoing` is the running spinner, an
615
+ * `svg` with no solid dot, and stays upstream's alone.
616
+ */
617
+ const ROW_FALLBACK_DOT = '[data-state="done"], [data-state="warning"]';
618
+
669
619
  /**
670
- * Session-row hover-card line — everything the action word cannot say: which
671
- * checkout the row's status describes, and which pull request the action
672
- * refers to. It renders through the upstream sidebar.session.row.hover
673
- * seat, inside the card upstream already opens for a row.
620
+ * The fallback's stylesheet. The plugin paints the token into the occupied
621
+ * 16px cell from a marker attribute, so it adds NO foreign node to a
622
+ * React-managed cell: the dot element steps aside and a `::after` draws the
623
+ * node half's glyph, coloured by the severity custom property.
674
624
  *
675
- * This matters MORE now that the row shows only an imperative: `merge` with no
676
- * way to see merge WHAT would be a nag rather than a hint, so the line appears
677
- * whenever there is something to name — a worktree, a pull request, or both.
625
+ * `:has()` keeps the real seat and the fallback exclusive even during the
626
+ * one commit where a row flips back to idle: a cell that already holds
627
+ * `data-git-badge-leading` never paints the overlay. A browser without
628
+ * `:has()` drops the whole rule, so the fallback is simply absent — it can
629
+ * never double-draw.
630
+ */
631
+ const ROW_FALLBACK_OVERLAY = "[data-git-badge-overlay]:not(:has([data-git-badge-leading]))";
632
+ const ROW_FALLBACK_CSS = [
633
+ ROW_FALLBACK_OVERLAY + " > span:first-child > " + ROW_FALLBACK_DOT + " { display: none; }",
634
+ ROW_FALLBACK_OVERLAY + " > span:first-child::after {",
635
+ ' content: var(--git-badge-glyph, "");',
636
+ " display: inline-flex;",
637
+ " align-items: center;",
638
+ " justify-content: center;",
639
+ " width: 16px;",
640
+ " height: 20px;",
641
+ " color: var(--git-badge-color, currentColor);",
642
+ " font-size: 13px;",
643
+ " line-height: 1;",
644
+ "}",
645
+ ROW_FALLBACK_OVERLAY + "[data-git-badge-drawn] > span:first-child::after {",
646
+ ' content: "";',
647
+ " background-color: var(--git-badge-color, currentColor);",
648
+ " -webkit-mask-image: var(--git-badge-mask-src);",
649
+ " mask-image: var(--git-badge-mask-src);",
650
+ " -webkit-mask-repeat: no-repeat;",
651
+ " mask-repeat: no-repeat;",
652
+ " -webkit-mask-position: center;",
653
+ " mask-position: center;",
654
+ " -webkit-mask-size: var(--git-badge-mask-size, 12px);",
655
+ " mask-size: var(--git-badge-mask-size, 12px);",
656
+ "}"
657
+ ].join("\n");
658
+
659
+ /**
660
+ * One `DRAWN_ICONS` entry as a CSS mask URL, so the fallback paints the
661
+ * EXACT artwork the real seat draws. The mask supplies the shape and the
662
+ * element's `background-color` supplies the severity colour, which is why
663
+ * the SVG is painted black: only its alpha is read.
664
+ */
665
+ function glyphMaskDataUri(icon) {
666
+ const shapes = icon.shapes.map((shape) => {
667
+ if (shape.circle === true) return '<circle cx="6" cy="6" r="5"/>';
668
+ if (shape.stroke === true) return '<path d="' + shape.d + '" fill="none" stroke="#000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>';
669
+ return '<path d="' + shape.d + '" fill="#000"/>';
670
+ }).join("");
671
+ const svg = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="' + icon.viewBox + '">' + shapes + "</svg>";
672
+ return 'url("data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '")';
673
+ }
674
+
675
+ /**
676
+ * The fallback's mark for a token, or undefined when the dot should stay.
678
677
  *
679
- * It makes no request of its own: the same `{ pr: true }` query as the badge
680
- * above resolves to the same cache key, so a card that mounts for every row
681
- * still costs one fetch per row, and a cold cache is served by the badge's
682
- * own in-flight request. That is why the badge must keep asking for `pr=1`.
678
+ * `in sync` is deliberately excluded: it is a disc, the row's own dot is
679
+ * also a dot, and replacing an unread marker with "nothing to do" would
680
+ * throw away the only signal that the turn ended. Only a real action earns
681
+ * the cell.
682
+ */
683
+ function fallbackGlyph(token, severity) {
684
+ if (typeof token !== "string" || token === "" || token === "in sync") return void 0;
685
+ const colour = SEVERITY_FILL[severity];
686
+ const drawn = DRAWN_ICONS[token];
687
+ if (drawn !== void 0) return { mask: glyphMaskDataUri(drawn), size: drawn.size + "px", colour };
688
+ const glyph = ACTION_GLYPH[token];
689
+ if (glyph === void 0) return void 0;
690
+ return { glyph, colour };
691
+ }
692
+
693
+ /** Write one mark onto the row element. */
694
+ function applyFallbackMark(host, mark) {
695
+ host.setAttribute("data-git-badge-overlay", "");
696
+ if (mark.mask !== void 0) {
697
+ host.setAttribute("data-git-badge-drawn", "");
698
+ host.style.setProperty("--git-badge-mask-src", mark.mask);
699
+ host.style.setProperty("--git-badge-mask-size", mark.size);
700
+ host.style.removeProperty("--git-badge-glyph");
701
+ } else {
702
+ host.removeAttribute("data-git-badge-drawn");
703
+ host.style.removeProperty("--git-badge-mask-src");
704
+ host.style.removeProperty("--git-badge-mask-size");
705
+ // JSON.stringify keeps the quotes `content: var(...)` needs.
706
+ host.style.setProperty("--git-badge-glyph", JSON.stringify(mark.glyph));
707
+ }
708
+ if (mark.colour === void 0) host.style.removeProperty("--git-badge-color");
709
+ else host.style.setProperty("--git-badge-color", mark.colour);
710
+ }
711
+
712
+ /** Remove every trace of a mark, so the row is exactly as upstream left it. */
713
+ function clearFallbackMark(host) {
714
+ if (host === void 0 || host === null) return;
715
+ host.removeAttribute("data-git-badge-overlay");
716
+ host.removeAttribute("data-git-badge-drawn");
717
+ host.style.removeProperty("--git-badge-glyph");
718
+ host.style.removeProperty("--git-badge-mask-src");
719
+ host.style.removeProperty("--git-badge-mask-size");
720
+ host.style.removeProperty("--git-badge-color");
721
+ }
722
+
723
+ /** The row (`[data-row-key]`) an element sits in, or undefined. */
724
+ function rowHost(node) {
725
+ const closest = node?.closest;
726
+ if (typeof closest !== "function") return void 0;
727
+ return closest.call(node, "[data-row-key]") ?? void 0;
728
+ }
729
+
730
+ /**
731
+ * The whole fallback decision in one testable call: does the cell hold a
732
+ * SOLID dot, and does the node half have an action to show? The cell is the
733
+ * authority, so no client-side copy of upstream's status precedence can
734
+ * drift. Returns whether the overlay is on.
735
+ */
736
+ function syncRowFallback(host, info) {
737
+ if (host === void 0 || host === null) return false;
738
+ const cell = host.firstElementChild;
739
+ const occupied = cell !== void 0 && cell !== null && typeof cell.querySelector === "function" && cell.querySelector(ROW_FALLBACK_DOT) !== null;
740
+ if (!occupied) {
741
+ clearFallbackMark(host);
742
+ return false;
743
+ }
744
+ const token = info !== void 0 && info !== null && info.git === true ? info.rowToken : void 0;
745
+ const mark = fallbackGlyph(token, info?.rowSeverity);
746
+ if (mark === void 0) {
747
+ clearFallbackMark(host);
748
+ return false;
749
+ }
750
+ applyFallbackMark(host, mark);
751
+ return true;
752
+ }
753
+
754
+ /** One stylesheet per page; the loader tags it for its own bookkeeping. */
755
+ let rowFallbackStyle = false;
756
+ function ensureRowFallbackStyle() {
757
+ if (rowFallbackStyle) return;
758
+ if (typeof document === "undefined" || document.head === void 0 || document.head === null) return;
759
+ try {
760
+ const style = document.createElement("style");
761
+ style.setAttribute("data-plugin", "dsh-git-badge");
762
+ style.textContent = ROW_FALLBACK_CSS;
763
+ document.head.appendChild(style);
764
+ rowFallbackStyle = true;
765
+ } catch {
766
+ // a missing or closed <head> must never break the badge
767
+ }
768
+ }
769
+
770
+ /**
771
+ * The marker that carries the fallback. It MUST ride
772
+ * `sidebar.workspaces.session.row.action`, because that strip is hidden by
773
+ * CSS rather than unmounted: it is the one per-row seat React mounts in
774
+ * every state, so it is the only place from which a row can be marked while
775
+ * upstream's dot owns the leading cell. It renders a hidden span and adds
776
+ * no button.
777
+ */
778
+ function RowFallbackGlyph({ sessionId }) {
779
+ const ref = react.useRef(null);
780
+ const [occupied, setOccupied] = react.useState(false);
781
+ // `enabled` is the LAZY gate: a row with no solid dot keeps costing
782
+ // nothing, exactly as it did before the fallback existed.
783
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true, enabled: occupied });
784
+ react.useEffect(() => {
785
+ ensureRowFallbackStyle();
786
+ const host = rowHost(ref.current);
787
+ if (host === void 0) return;
788
+ const cell = host.firstElementChild;
789
+ if (cell === void 0 || cell === null || typeof cell.querySelector !== "function") return;
790
+ const sync = () => setOccupied(cell.querySelector(ROW_FALLBACK_DOT) !== null);
791
+ sync();
792
+ if (typeof MutationObserver !== "function") return;
793
+ const observer = new MutationObserver(sync);
794
+ // The dot swaps in as a child; a state change edits `data-state` in
795
+ // place, so both must be watched. The row's own attributes are NOT
796
+ // observed, which is what keeps the mark from re-triggering itself.
797
+ observer.observe(cell, { childList: true, subtree: true, attributes: true, attributeFilter: ["data-state"] });
798
+ return () => observer.disconnect();
799
+ }, [sessionId]);
800
+ // Layout effect: the mark lands in the same commit that reveals the dot,
801
+ // so the fallback does not flash the green/orange dot first.
802
+ react.useLayoutEffect(() => {
803
+ syncRowFallback(rowHost(ref.current), info);
804
+ }, [info, occupied]);
805
+ return react_jsx_runtime.jsx("span", { ref, "aria-hidden": true, style: { display: "none" } });
806
+ }
807
+ //#endregion
808
+
809
+ const CARD_CONTAINER = {
810
+ display: "flex",
811
+ flexDirection: "column",
812
+ gap: "4px",
813
+ fontSize: "12px",
814
+ lineHeight: "18px",
815
+ // Auto-width: the card fits its widest row instead of a guessed
816
+ // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
817
+ // cap or IT becomes the clamp.
818
+ width: "max-content",
819
+ maxWidth: "600px"
820
+ };
821
+ const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
822
+ const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
823
+ const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere", whiteSpace: "normal" };
824
+ // The chip card's HEADER: the action sentence as a heading, not a labelled
825
+ // row. `fontWeight` is also the hook the suite uses to find it.
826
+ const CHIP_HEADER = { display: "flex", alignItems: "baseline", gap: "6px", fontWeight: 600 };
827
+ // A section rule between the checkout rows and the branch rows. The label
828
+ // makes the split explicit instead of leaving `state`/`base` to look like
829
+ // more checkout detail.
830
+ const CARD_DIVIDER = {
831
+ marginTop: "4px",
832
+ paddingTop: "6px",
833
+ borderTop: ".5px solid var(--dsw-alias-border-l4, #e4e8eb)",
834
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
835
+ fontSize: "10px",
836
+ lineHeight: "14px",
837
+ letterSpacing: ".08em",
838
+ textTransform: "uppercase"
839
+ };
840
+
841
+ /** One label/value row — the shape every hover body shares. */
842
+ function cardRow(label, value) {
843
+ return react_jsx_runtime.jsxs("div", {
844
+ style: CARD_ROW,
845
+ children: [
846
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
847
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
848
+ ]
849
+ }, label);
850
+ }
851
+
852
+ /** Push one label/value row unless the value is empty. */
853
+ function addRow(rows, label, value) {
854
+ if (value === void 0 || value === null || value === "") return;
855
+ rows.push(cardRow(label, value));
856
+ }
857
+
858
+ /**
859
+ * The action SENTENCE as a raw value. SHARED by the session-row card
860
+ * (actionRow wraps it in the card label/value shape) and the chip card
861
+ * (actionHeader wraps it in a heading), so the two cannot describe the icon
862
+ * differently. Returns undefined when the checkout has no token.
863
+ *
864
+ * The glyph is DECORATION here: the word follows it, so `aria-hidden` keeps
865
+ * the accessible name the bare action.
866
+ */
867
+ function actionRow(info) {
868
+ const token = info?.rowToken;
869
+ if (typeof token !== "string" || token === "") return void 0;
870
+ const why = typeof info.rowTokenWhy === "string" && info.rowTokenWhy !== "" ? info.rowTokenWhy : void 0;
871
+ const colour = SEVERITY_FILL[info.rowSeverity];
872
+ const value = [];
873
+ if (DRAWN_ICONS[token] !== void 0 || ACTION_GLYPH[token] !== void 0) {
874
+ value.push(react_jsx_runtime.jsx("span", {
875
+ "aria-hidden": true,
876
+ style: { color: colour === void 0 ? META_STYLE.color : colour, fontWeight: 700, display: "inline-flex", alignItems: "center" },
877
+ children: actionGlyphNode(token)
878
+ }, "action-glyph"));
879
+ value.push(" ");
880
+ }
881
+ value.push(token + (why === void 0 ? "" : " \u2014 " + why));
882
+ return value;
883
+ }
884
+
885
+ /**
886
+ * The chip card's HEADER: the same sentence actionRow produces, rendered as a
887
+ * heading so the checkout's verdict leads the card instead of sitting in a
888
+ * labelled row. It calls actionRow, so the two surfaces cannot drift.
889
+ */
890
+ function actionHeader(info) {
891
+ const value = actionRow(info);
892
+ if (value === void 0) return void 0;
893
+ return react_jsx_runtime.jsx("div", { style: CHIP_HEADER, children: value }, "action-header");
894
+ }
895
+
896
+ /**
897
+ * Session-row hover card — everything the action glyph cannot say: what the
898
+ * action means and why, which checkout the row's status describes, and which
899
+ * pull request it refers to. It renders through the upstream
900
+ * sidebar.session.row.hover seat, inside the card upstream already opens for
901
+ * a row.
902
+ *
903
+ * LABEL/VALUE ROWS, the shape the chip card uses. The `action` row comes from
904
+ * the shared actionRow() helper, so the two cards cannot describe the icon
905
+ * differently; `checkout` and `pull request` follow.
906
+ *
907
+ * This matters MORE now that the row shows only a glyph: `merge` with no way
908
+ * to see merge WHAT would be a nag rather than a hint, so the card renders
909
+ * whenever there is something to name — the row's action, a worktree, a pull
910
+ * request, or any combination.
911
+ *
912
+ * It makes no request of its own: the same `{ pr: true }` query as the
913
+ * leading glyph resolves to the same cache key, so a card that mounts for
914
+ * every row still costs one fetch per row, and a cold cache is served by
915
+ * the glyph's own in-flight request.
683
916
  *
684
917
  * Only a WORKTREE's checkout is spelled out. For a main checkout the branch is
685
918
  * the ordinary case and repeating it on every session's card would be noise;
@@ -688,18 +921,35 @@ window.__ModuleLoader__.load({
688
921
  function SessionGitDetail({ sessionId }) {
689
922
  const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
690
923
  if (info === void 0 || info.git !== true) return null;
691
- const bits = [];
924
+ const rows = [];
925
+ const add = (label, value) => addRow(rows, label, value);
926
+ // The HEADER, the shape the chip card leads with: the same sentence from
927
+ // the same actionRow, so the two cards cannot describe the icon
928
+ // differently, and the sentence gains the 62px label column's width.
929
+ const header = actionHeader(info);
930
+ if (header !== void 0) rows.push(header);
692
931
  if (info.isWorktree === true) {
932
+ // WHICH tree, not just that there is one: the row's glyph cannot say
933
+ // it, and the branch is ordinary for a main checkout.
693
934
  const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
694
- const checkout = [name === "" ? String(info.branch) : name + " on " + info.branch];
695
- // the node half followed this tree because it is the one whose branch
696
- // has the open PR — say so, because nothing on the row can
697
-
698
- bits.push("checkout: " + checkout.join(" \u00B7 "));
935
+ add("checkout", name === "" ? String(info.branch) : name + " on " + info.branch);
699
936
  }
700
- const pr = formatPrDetail(info.pr);
701
- if (pr !== void 0) bits.push("pull request " + pr);
702
- return bits.length === 0 ? null : react_jsx_runtime.jsx("span", { style: META_STYLE, children: bits.join(" \u00B7 ") });
937
+ // The dirty breakdown, so a higher-ranked token cannot hide uncommitted
938
+ // work. formatFileBreakdown says "clean" when there is none; a clean row
939
+ // keeps its short card.
940
+ const changes = formatFileBreakdown(info);
941
+ if (changes !== "clean") add("changes", changes);
942
+ add("pull request", formatPrDetail(info.pr));
943
+ if (rows.length === 0) return null;
944
+ // The row's hover card is upstream's FIXED-WIDTH box: HoverCard.module.css
945
+ // pins `.card` to 244px. `width: max-content` (CARD_CONTAINER) would
946
+ // overflow it, so the stack FILLS the box instead and the value cells wrap
947
+ // inside it. The chip card is different — its Tooltip sizes to the content,
948
+ // so it keeps max-content.
949
+ return react_jsx_runtime.jsx("div", {
950
+ style: { ...CARD_CONTAINER, width: "100%", maxWidth: "100%" },
951
+ children: rows
952
+ });
703
953
  }
704
954
 
705
955
  /**
@@ -722,7 +972,7 @@ window.__ModuleLoader__.load({
722
972
  /** The PR's full state as text, for the card: "#142 · checks failing · draft · review approved". */
723
973
  function formatPrDetail(pr) {
724
974
  if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
725
- const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
975
+ const state = prStateLabel(pr);
726
976
  const bits = ["#" + pr.number, state];
727
977
  if (pr.draft === true) bits.push("draft");
728
978
  if (pr.review !== void 0) bits.push(String(pr.review).toLowerCase().replace(/_/g, " "));
@@ -810,50 +1060,6 @@ window.__ModuleLoader__.load({
810
1060
  });
811
1061
  }
812
1062
 
813
- /**
814
- * The PR token's own hover body — the commits IN THIS PULL REQUEST
815
- * (`base..HEAD`), and nothing else: the token already says the number and
816
- * CI state, so the one question left is "what is in it?". Same line format
817
- * as the branch hover's commit list (see CommitLine — one renderer, so the
818
- * columns line up in both by construction), and the card's own width
819
- * behaviour: full-width lines, nowrap, ellipsis.
820
- */
821
- function PrCommitsList({ info, pending = false }) {
822
- const commits = info?.prCommits;
823
- if (!Array.isArray(commits) || commits.length === 0) {
824
- // `pending`: the detail payload has not landed yet, so "no commits"
825
- // would be a false negative — name the pull request the token points
826
- // at instead, the way the mark's card degrades to the base response.
827
- if (pending) {
828
- return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatPrDetail(info?.pr) ?? "loading\u2026" });
829
- }
830
- return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
831
- }
832
- return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
833
- react_jsx_runtime.jsxs("div", {
834
- style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
835
- children: [
836
- ...commits.map((commit) => react_jsx_runtime.jsx(CommitLine, { commit }, commit.hash)),
837
- (typeof info.prCommitsTotal === "number" && info.prCommitsTotal > commits.length
838
- ? "\u2026 and " + (info.prCommitsTotal - commits.length) + " more"
839
- : null)
840
- ]
841
- })
842
- ] });
843
- }
844
-
845
- const CARD_CONTAINER = {
846
- display: "flex",
847
- flexDirection: "column",
848
- gap: "4px",
849
- fontSize: "12px",
850
- lineHeight: "18px",
851
- // Auto-width: the card fits its widest row instead of a guessed
852
- // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
853
- // cap or IT becomes the clamp.
854
- width: "max-content",
855
- maxWidth: "600px"
856
- };
857
1063
  /**
858
1064
  * The ✎n count's own hover body — the changed-file lists and NOTHING
859
1065
  * else: the full card stays on the status mark, and this tooltip answers
@@ -925,35 +1131,31 @@ window.__ModuleLoader__.load({
925
1131
  }
926
1132
 
927
1133
  /**
928
- * The branch name's own hover body — the branch's LINEAGE, and it ALWAYS
929
- * renders once the lazy detail payload has landed. Rows: upstream, sync
930
- * in words, the branch's last 10 commits (WHAT THIS BRANCH HAS, full
931
- * stop — no base-relative filtering, which empties on every push or
932
- * merge). The pull request has no row here — the chip's own PR token
933
- * already carries number, CI state and the link.
1134
+ * The branch LINEAGE rows, for the chip's one card. The state row always
1135
+ * renders (the pull-request verdict, or the base-branch note); the base row
1136
+ * follows when the counts are known; then the branch's last 10 commits
1137
+ * (WHAT THIS BRANCH HAS, full stop — no base-relative filtering, which
1138
+ * empties on every push or merge). The pull request has no row here — the
1139
+ * chip's own PR token already carries number, CI state and the link.
934
1140
  */
935
- function BranchLineage({ info }) {
1141
+ function lineageRows(info) {
936
1142
  const rows = [];
937
- const add = (label, value) => {
938
- if (value === void 0 || value === null || value === "") return;
939
- rows.push(cardRow(label, value));
940
- };
941
- // ONLY the merge axis, and it ALWAYS renders. The old upstream and sync
942
- // rows spoke about the push axis (the branch's own remote), which the
943
- // merge row subsumes: "1 ahead, 0 behind main" is the same fact stated
944
- // against the branch that matters, in words rather than arrows. A row
945
- // that can be absent is a row the reader has to reconstruct.
1143
+ const add = (label, value) => addRow(rows, label, value);
1144
+ // The STATE row always renders. The old upstream and sync rows spoke
1145
+ // about the push axis (the branch's own remote), which the state row
1146
+ // subsumes: the merge verdict is the fact that matters. The base counts
1147
+ // stay a SEPARATE row below, so a reader never reconstructs either.
946
1148
  const pr = info?.pr;
947
- const mergeParts = [];
1149
+ const stateParts = [];
948
1150
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
949
- if (pr.draft === true) mergeParts.push("blocked: draft");
1151
+ if (pr.draft === true) stateParts.push("blocked: draft");
950
1152
  else if (pr.mergeState === "BLOCKED") {
951
- mergeParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
1153
+ stateParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
952
1154
  : pr.state === "failing" ? "blocked: checks failing"
953
1155
  : "blocked");
954
- } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
955
- else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
956
- else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
1156
+ } else if (pr.state === "failing") stateParts.push("blocked: checks failing");
1157
+ else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) stateParts.push("ready to merge");
1158
+ else if (pr.review === "REVIEW_REQUIRED") stateParts.push("blocked: review required");
957
1159
  } else if (info?.defaultBranch === true) {
958
1160
  // The default branch is the BASE, not a branch with a missing PR:
959
1161
  // pull requests merge INTO it and none is ever opened FROM it, so
@@ -961,18 +1163,21 @@ window.__ModuleLoader__.load({
961
1163
  // exist. The node half computes `defaultBranch` for exactly this
962
1164
  // suppression (see the comment where it is set in lib/index.js);
963
1165
  // until now only the action rules consulted it, not this row.
964
- mergeParts.push("base branch \u00B7 pull requests merge into this");
1166
+ stateParts.push("base branch \u00B7 pull requests merge into this");
965
1167
  } else {
966
- mergeParts.push("no pull request");
1168
+ stateParts.push("no pull request");
967
1169
  }
968
- // The counts are this branch's standing against its BASE, so on the
969
- // base itself they are self-referential ("N ahead, M behind main" while
970
- // ON main) and the divergence they would state is already the chip's own
971
- // \u2191a \u2193b token. Suppress them on the default branch, with or without a PR.
1170
+ // The state row states the PR verdict and nothing else.
1171
+ add("state", stateParts.join(" \u00B7 "));
1172
+ // The counts are this branch's standing against its BASE, so on the base
1173
+ // itself they are self-referential ("N ahead, M behind main" while ON
1174
+ // main) and the divergence they would state is already the chip's own
1175
+ // \u2191a \u2193b token. Suppress them on the default branch, with or without a
1176
+ // PR. A SEPARATE row from state: whether the PR can merge and how far the
1177
+ // branch is from its base are two facts, so neither hides the other.
972
1178
  if (info?.defaultBranch !== true && (info?.mainAhead !== void 0 || info?.mainBehind !== void 0)) {
973
- mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
1179
+ add("base", (info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind " + baseNameFor(info));
974
1180
  }
975
- add("merge", mergeParts.join(" \u00B7 "));
976
1181
  const commits = info?.branchCommits;
977
1182
  if (Array.isArray(commits) && commits.length > 0) {
978
1183
  // Full-width block, not the label/value two-column row: commit lines
@@ -993,13 +1198,9 @@ window.__ModuleLoader__.load({
993
1198
  }
994
1199
  // NO pull-request row: the chip's own PR token (number, CI state, link)
995
1200
  // carries it — a second rendering two pixels away is pure duplication.
996
- return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
1201
+ return rows;
997
1202
  }
998
1203
 
999
- const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
1000
- const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
1001
- const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
1002
-
1003
1204
  // The host's plugin-card chrome, reproduced inline: its CSS module is not
1004
1205
  // exported to plugins, so these values mirror PluginCard.module.css so our
1005
1206
  // card sits in the same list as its siblings.
@@ -1045,52 +1246,43 @@ window.__ModuleLoader__.load({
1045
1246
  flex: "none"
1046
1247
  };
1047
1248
 
1048
- /** One label/value row — the shape every hover body shares. */
1049
- function cardRow(label, value) {
1050
- return react_jsx_runtime.jsxs("div", {
1051
- style: CARD_ROW,
1052
- children: [
1053
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
1054
- react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
1055
- ]
1056
- }, label);
1057
- }
1058
-
1059
-
1060
1249
  /**
1061
- * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
1062
- * over fields the status response already carries, plus the `detail=1`
1063
- * additions (recent commits, stash) when they have arrived. Everything is
1064
- * spelled out in text, so no fact here depends on a colour or a glyph.
1250
+ * The checkout's verdict rows, for the chip's one card — INPUT CHIP ONLY.
1251
+ * Pure presentation over fields the status response already carries, plus
1252
+ * the `detail=1` additions (recent commits, stash) when they have arrived.
1253
+ * Everything is spelled out in text, so no fact here depends on a colour or
1254
+ * a glyph.
1065
1255
  */
1066
- function HoverCard({ info, detail }) {
1256
+ function statusRows(info, detail) {
1067
1257
  // the detail payload is a superset of the plain one, so it wins when it
1068
1258
  // has landed; until then the card still shows the base facts
1069
1259
  const data = detail === void 0 ? info : detail;
1070
1260
  const rows = [];
1071
- const add = (label, value) => {
1072
- if (value === void 0 || value === null || value === "") return;
1073
- rows.push(cardRow(label, value));
1074
- };
1075
- // The "action" row — the top line, in the card's ordinary row layout:
1076
- // the SERVER's ranked suggestion, and nothing else. The client's
1077
- // extras (secondary picker entries) must never promote themselves into
1078
- // this line: when the server says there is nothing to do — clean and
1079
- // synced — the row is omitted, and "no suggestion" stays a suggestion.
1080
- // Plain text, no affordance: the actionable surface is the (+) menu's
1081
- // /gh picker, and this row is the read-only pointer to it.
1261
+ const add = (label, value) => addRow(rows, label, value);
1262
+ // The ACTION header, first: the SAME sentence the session-row card renders,
1263
+ // from the same helper, so the chip card explains the icon the row shows.
1264
+ // It is a heading, not a labelled row, so the verdict leads the card.
1265
+ const header = actionHeader(data);
1266
+ if (header !== void 0) rows.push(header);
1267
+ // The "next step" row — the SERVER's ranked suggestion, and nothing else. The
1268
+ // client's extras (secondary picker entries) must never promote
1269
+ // themselves into this line: when the server says there is nothing to do
1270
+ // — clean and synced — the row is omitted, and "no suggestion" stays a
1271
+ // suggestion. Plain text, no affordance: the actionable surface is the
1272
+ // (+) menu's /gh picker, and this row is the read-only pointer to it.
1082
1273
  const topAction = data.next;
1083
1274
  if (topAction !== void 0) {
1084
1275
  // git-comment convention: the invocation, two spaces, then "# "
1085
- // plus what it does
1086
- add("action", "/gh " + topAction.args + " # " + topAction.what);
1276
+ // plus what it does. The marker makes the one actionable line findable
1277
+ // among the detail rows.
1278
+ add("next step", "\u25B8 /gh " + topAction.args + " # " + topAction.what);
1087
1279
  }
1088
1280
  // The branch lineage rows (branch, upstream, sync, pull request) moved
1089
1281
  // to the branch NAME's hover — the card is the checkout's verdict, not
1090
1282
  // the branch's CV. The branch name is also the chip's own text, so a
1091
1283
  // card row for it was pure repetition.
1092
- add("files", formatFileBreakdown(info));
1093
- add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
1284
+ add("changes", formatFileBreakdown(data));
1285
+ add("operation", data.operation === void 0 || data.operation === null ? void 0 : String(data.operation));
1094
1286
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
1095
1287
  // the branch alone, so this row is the only place the tree is named.
1096
1288
  add("worktree", worktreeDetail(data));
@@ -1102,10 +1294,36 @@ window.__ModuleLoader__.load({
1102
1294
  // The conversation's OWN directory, present only when the badge is
1103
1295
  // describing a followed worktree — the chip's branch and counts are the
1104
1296
  // tree's in that case, so this is where the checkout's own state stays
1105
- // visible. Absent until the `detail=1` response lands, like commits/stash.
1106
- if (data.worktreeFollowed === true) add("checkout", formatCheckoutDetail(data.checkout));
1297
+ // visible. Labelled `own checkout` because `checkout` now names the
1298
+ // checkout a session-row card describes. Absent until the `detail=1`
1299
+ // response lands, like commits/stash.
1300
+ if (data.worktreeFollowed === true) add("own checkout", formatCheckoutDetail(data.checkout));
1107
1301
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
1108
- return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
1302
+ return rows;
1303
+ }
1304
+
1305
+ /**
1306
+ * The chip's ONE card, behind the leading cluster (the action glyph and the
1307
+ * branch name together). It stacks the checkout's verdict rows over the
1308
+ * branch's lineage rows, so one hover answers both "what is this checkout
1309
+ * doing" and "what is on this branch".
1310
+ *
1311
+ * One card, not two: the glyph and the branch are one target, and the branch
1312
+ * is the element that always exists — so a clean checkout, which has no
1313
+ * glyph, still opens the card from its branch name.
1314
+ */
1315
+ function ChipCard({ info, detail }) {
1316
+ const data = detail === void 0 ? info : detail;
1317
+ const rows = statusRows(info, detail);
1318
+ const lineage = lineageRows(data);
1319
+ return react_jsx_runtime.jsx("div", {
1320
+ style: CARD_CONTAINER,
1321
+ children: [
1322
+ ...rows,
1323
+ lineage.length === 0 ? null : react_jsx_runtime.jsx("div", { style: CARD_DIVIDER, children: "branch" }, "branch-divider"),
1324
+ ...lineage
1325
+ ]
1326
+ });
1109
1327
  }
1110
1328
 
1111
1329
  //#region composer chip (upstream additive surface: conversation.input.left)
@@ -1128,211 +1346,152 @@ window.__ModuleLoader__.load({
1128
1346
  }
1129
1347
 
1130
1348
  /**
1131
- * Chip line in the input row: git state of the workspace the CURRENT
1132
- * conversation is attached to. It targets the session id and lets the node
1133
- * half resolve the workspace, so this surface needs no `workspaces`
1134
- * service, no cwd and no path plumbing at all. Works on unpatched installs
1135
- * (conversation.input.left is an upstream additive slot), and stays useful
1136
- * next to the sidebar rows because it is context-anchored ("where am I")
1137
- * rather than surveying.
1138
- *
1139
- * This is the ONLY surface that asks the node half for the extras (`pr=1`
1140
- * always, `detail=1` on hover), because it is the only one that renders
1141
- * them — see targetQuery.
1349
+ * The chip's leading action glyph, or null when the checkout needs none.
1350
+ * The SAME token the sidebar row draws, in the node half's severity colour.
1351
+ * No token means NO GLYPH: a clean, synced checkout has no action to triage,
1352
+ * so it shows no icon rather than a decorative dot.
1142
1353
  */
1354
+ function chipGlyphNode(info) {
1355
+ const token = info.rowToken;
1356
+ if (typeof token !== "string" || token === "") return null;
1357
+ return react_jsx_runtime.jsx("span", {
1358
+ key: "glyph",
1359
+ role: "img",
1360
+ "aria-label": "git action: " + token,
1361
+ style: { color: SEVERITY_FILL[info.rowSeverity] ?? META_STYLE.color, flex: "none", ...GLYPH_TEXT, display: "inline-flex", alignItems: "center" },
1362
+ children: actionGlyphNode(token)
1363
+ });
1364
+ }
1365
+
1143
1366
  /**
1144
- * Set LATE (see apply) — never at apply() time, because the workspace
1145
- * browser may not have declared the seam yet and a hint set then would
1146
- * lie to patched installs for the whole session. Every chip render reads
1147
- * it, so it reaches the DOM on the next refresh after it is set.
1367
+ * The shared anchor for the chip's two links — the branch compare link and
1368
+ * the PR token. Both were near-identical `<a>` elements; this is the one
1369
+ * copy of target, rel, cursor and handler wiring, so they cannot drift.
1370
+ * `underline` is the hover/focus state and `children` is the label.
1148
1371
  */
1149
- let seamHint = null;
1372
+ function chipAnchor(props) {
1373
+ return react_jsx_runtime.jsx("a", {
1374
+ href: props.href,
1375
+ target: "_blank",
1376
+ rel: "noopener noreferrer",
1377
+ "aria-label": props.ariaLabel,
1378
+ onPointerEnter: props.onPointerEnter,
1379
+ onPointerLeave: props.onPointerLeave,
1380
+ onFocus: props.onFocus,
1381
+ onBlur: props.onBlur,
1382
+ style: {
1383
+ color: "inherit",
1384
+ textDecoration: props.underline ? "underline" : "none",
1385
+ cursor: "pointer"
1386
+ },
1387
+ children: props.children
1388
+ });
1389
+ }
1150
1390
 
1151
1391
  /**
1152
- * Dismissal of the seam-absent notice, deliberately MODULE-level rather
1153
- * than component state: a useState flag would un-dismiss when the chip
1154
- * remounts (a sidebar toggle, a route change), and the × the user just
1155
- * clicked would silently stop meaning anything. Session-scoped on
1156
- * purpose — it resets at the next boot, so a seam that is STILL absent
1157
- * asks again, while one fixed by apply + restart never nags at all.
1392
+ * The branch name, as a compare anchor when the detail payload vouches an
1393
+ * http(s) URL. The sync arrows ride WITH the branch: they are the branch's
1394
+ * standing against its upstream, so the same hover and the same link cover
1395
+ * them. The ✎n count keeps its own surface.
1158
1396
  */
1159
- let seamNoticeDismissed = false;
1160
-
1161
- function ComposerGitChip({ sessionId }) {
1162
- const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1163
- // the PR/CI token is always on the chip, so its fetch is not gated
1164
- const info = useGitStatus(target, { pr: true });
1165
- // The card's extras cost a `log -10` plus a stash list, so they are
1166
- // fetched only once a pointer actually RESTS on the chip, then kept
1167
- // fresh by the same SSE path. Eagerly asking would add both invocations
1168
- // to every refresh — and a refresh fires on every file edit.
1169
- const [hovered, setHovered] = react.useState(false);
1170
- // the PR token's own hover/focus state: it underlines on hover (and on
1171
- // focus) rather than at rest, and that underline is applied inline below
1172
- const [linkHover, setLinkHover] = react.useState(false);
1173
- // seamHint is module state set once, 5s after boot; dismissing it needs a
1174
- // re-render that no fetch will schedule, so the × bumps a counter here.
1175
- const [, bumpNotice] = react.useState(0);
1176
- const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
1177
- // the branch link's hover underline — the PR token's pattern: the
1178
- // affordance appears on hover/focus, never at rest. Its OWN state, not
1179
- // the PR token's linkHover: two links sharing one flag would
1180
- // underline both when either is hovered.
1181
- const [branchLinkHover, setBranchLinkHover] = react.useState(false);
1182
- const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
1183
- if (info === void 0 || info.git !== true) return null;
1397
+ function chipBranchInner(info, detail, isBranchLink, hoveredLink, setHovered, setHoveredLink) {
1398
+ const sync = formatBaseSync(info);
1399
+ const branchLabel = info.branch + sync;
1400
+ if (!isBranchLink) return branchLabel;
1401
+ return chipAnchor({
1402
+ href: detail.compareUrl,
1403
+ ariaLabel: "Compare " + info.branch + " with " + baseNameFor(info) + " on GitHub" + (sync === "" ? "" : ", " + (info.mainAhead || 0) + " ahead and " + (info.mainBehind || 0) + " behind " + baseNameFor(info)),
1404
+ underline: hoveredLink === "branch",
1405
+ onPointerEnter: () => setHoveredLink("branch"),
1406
+ onPointerLeave: () => setHoveredLink(null),
1407
+ onFocus: () => { setHovered(true); setHoveredLink("branch"); },
1408
+ onBlur: () => setHoveredLink(null),
1409
+ children: branchLabel
1410
+ });
1411
+ }
1184
1412
 
1185
- // The status mark wrapped in the chip's hover card — the card's ONLY
1186
- // anchor. Pointer rest here (and only here) enables the detail=1 fetch,
1187
- // so the extras are paid for exactly when the card that can render them
1188
- // is about to open. Defined inside the component: it closes over the
1189
- // hovered state that gates the detail fetch.
1190
- const hoverableMark = withTooltip(
1413
+ /**
1414
+ * The LEADING CLUSTER — the action glyph (when the checkout needs one) and
1415
+ * the branch name — wrapped in the one hover card. It is the chip card's ONLY
1416
+ * anchor: pointer rest here enables the detail=1 fetch.
1417
+ */
1418
+ function chipLeadingHover(info, detail, tokenGlyph, branchInner, isBranchLink, setHovered) {
1419
+ return withTooltip(
1191
1420
  react_jsx_runtime.jsx("span", {
1192
1421
  onPointerEnter: () => setHovered(true),
1193
- style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
1194
- children: react_jsx_runtime.jsx(StatusMark, { info })
1422
+ style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", cursor: isBranchLink ? "pointer" : "default" },
1423
+ children: [tokenGlyph, react_jsx_runtime.jsx("span", { key: "branch", children: branchInner })]
1195
1424
  }),
1196
- // a function label keeps the card's element tree out of every render
1197
- // until the tooltip actually opens
1198
- () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1199
- 600 // matches the body cap: the tooltip must never be the clamp
1425
+ () => react_jsx_runtime.jsx(ChipCard, { info, detail }),
1426
+ 600
1200
1427
  );
1201
- // the mark is an element now rather than a leading glyph in the string, so
1202
- // the SAME StatusMark the sidebar row draws carries the status here too;
1203
- // the container's 4px gap supplies the space the emoji's own did
1204
- // The branch NAME is its own hover surface: the commits this branch adds
1205
- // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1206
- // the leading text (operation token) and the count keep their own
1207
- // surfaces. The wrapper is always mounted (see withTooltip); without the
1208
- // Tooltip primitive the name renders plain.
1209
- const branchHover = (() => {
1210
- // The branch name links to the compare view (base...branch) — the web
1211
- // page of exactly what the lineage tooltip lists. Vouched http(s)
1212
- // only (the node half rebuilt the URL from parsed parts); new tab,
1213
- // never navigating the conversation away.
1214
- const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1215
- // The sync arrows ride WITH the branch: they are the branch's standing
1216
- // against its upstream, so the same hover (lineage) and the same link
1217
- // (compare view, where the arrows are the diff) cover them. The ✎n
1218
- // count stays its own surface — it answers a different question.
1219
- const sync = formatSync(info);
1220
- const label = info.branch + sync;
1221
- // The Tooltip's CHILD must not change element type once the detail
1222
- // payload lands: the primitive opens on the child's mouseenter and has
1223
- // no `open` prop, so a node replaced under the pointer may never be
1224
- // re-entered. The outer span is the stable hover surface; only this
1225
- // inner node swaps (plain text -> anchor) as the compare URL arrives.
1226
- const inner = isLink
1227
- ? react_jsx_runtime.jsx("a", {
1228
- href: detail.compareUrl,
1229
- target: "_blank",
1230
- rel: "noopener noreferrer",
1231
- "aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
1232
- onPointerEnter: () => setBranchLinkHover(true),
1233
- onPointerLeave: () => setBranchLinkHover(false),
1234
- // focus joins hover: a keyboard user needs the same underline, and
1235
- // focus (unlike a pointer) must also start the detail fetch
1236
- onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1237
- onBlur: () => setBranchLinkHover(false),
1238
- style: {
1239
- color: "inherit",
1240
- textDecoration: branchLinkHover ? "underline" : "none",
1241
- cursor: "pointer"
1242
- },
1243
- children: label
1244
- })
1245
- : label;
1246
- const branch = react_jsx_runtime.jsx("span", {
1247
- onPointerEnter: () => setHovered(true),
1248
- style: { cursor: isLink ? "pointer" : "default" },
1249
- children: inner
1250
- });
1251
- // ALWAYS wrapped. A wrapper mounted only once the lazy detail arrives
1252
- // cannot open for the visit that fetched it (see the child-stability
1253
- // note above); until the payload lands the lineage degrades to the base
1254
- // response, the way the mark's own card already does.
1255
- return withTooltip(branch, () => react_jsx_runtime.jsx(BranchLineage, { info: detail ?? info }), 480);
1256
- })();
1257
- const text = formatOperationToken(info);
1258
- // The ✎n count is its OWN hover surface: a names-only tooltip (see
1259
- // UntrackedList). It shares the mark's lazy detail fetch — resting on
1260
- // either triggers it once — and degrades to a plain count on a shell
1261
- // without the Tooltip primitive.
1262
- const countHover = (() => {
1263
- // ✎n only: the sync arrows moved to the branch's surface above
1264
- const countText = formatFileCount(info);
1265
- const count = react_jsx_runtime.jsx("span", {
1266
- onPointerEnter: () => setHovered(true),
1428
+ }
1429
+
1430
+ /**
1431
+ * The ✎n count as its OWN hover surface: a names-only tooltip. It shares
1432
+ * the mark's lazy detail fetch and degrades to a plain count without the
1433
+ * Tooltip primitive.
1434
+ */
1435
+ function chipCountHover(info, detail, setHovered) {
1436
+ const countText = formatFileCount(info);
1437
+ const count = react_jsx_runtime.jsx("span", {
1438
+ onPointerEnter: () => setHovered(true),
1439
+ style: { cursor: "default" },
1440
+ children: countText
1441
+ });
1442
+ const pending = detail === void 0 || detail === null;
1443
+ return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1444
+ }
1445
+
1446
+ /**
1447
+ * The PR token: the number, the CI state glyph and the GitHub link, and
1448
+ * nothing else. It has NO hover card of its own — the chip/branch card
1449
+ * already lists the branch's commits. ONE element, split so the STATE GLYPH
1450
+ * can take the CI colour while the number keeps the chip's muted colour.
1451
+ */
1452
+ function chipPrTokenHover(info, hoveredLink, setHoveredLink) {
1453
+ const prParts = prTokenParts(info);
1454
+ const prToken = prParts === null ? null : [
1455
+ prParts.number,
1456
+ prParts.glyph === "" ? null : react_jsx_runtime.jsx("span", {
1457
+ key: "pr-state",
1458
+ style: { color: prParts.colour },
1459
+ children: " " + prParts.glyph
1460
+ }),
1461
+ prParts.draft
1462
+ ];
1463
+ const prUrl = prLinkUrl(info);
1464
+ if (prUrl === void 0) {
1465
+ // plain token: no hover, so it wires no pointer handlers
1466
+ return react_jsx_runtime.jsx("span", {
1467
+ // the glyph is not the only channel: the token says what the CI state IS
1468
+ "aria-label": prTokenLabel(info),
1267
1469
  style: { cursor: "default" },
1268
- children: countText
1470
+ children: prToken
1269
1471
  });
1270
- // always wrapped, for the same reason as the branch: the primitive
1271
- // opens on the child's mouseenter, so a wrapper added after the
1272
- // pointer arrives never opens. While the names are in flight the label
1273
- // restates the breakdown the base response already carries.
1274
- const pending = detail === void 0 || detail === null;
1275
- return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1276
- })();
1277
- const prToken = formatPrToken(info);
1278
- const prUrl = prLinkUrl(info);
1279
- // The PR token, with its own hover: the commits IN THIS PR (see
1280
- // PrCommitsList). Built here so the link/plain cases and the tooltip
1281
- // compose once instead of nesting a third conditional in the children.
1282
- // The token is its OWN detail gate: resting on it (or focusing it)
1283
- // enables the same `detail=1` fetch the mark and the branch do. Without
1284
- // that, a pointer going straight to the token had rested on nothing that
1285
- // fetches, so `prCommits` was never requested and this hover could not
1286
- // open at all — the data it renders is detail-only.
1287
- const prTokenHover = (() => {
1288
- const token = prUrl === void 0
1289
- ? react_jsx_runtime.jsx("span", {
1290
- // the glyph is not the only channel: the token says what the
1291
- // CI state IS, for anyone who cannot see it
1292
- "aria-label": prTokenLabel(info),
1293
- onPointerEnter: () => setHovered(true),
1294
- style: { cursor: "default" },
1295
- children: prToken
1296
- })
1297
- : react_jsx_runtime.jsx("a", {
1298
- href: prUrl,
1299
- // a new tab, because navigating THIS one away from the app
1300
- // would lose the conversation; noopener/noreferrer keep the
1301
- // opened tab from getting a handle on it
1302
- target: "_blank",
1303
- rel: "noopener noreferrer",
1304
- "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1305
- // the chip's own colour rather than the browser's link blue /
1306
- // visited purple, so the token still reads as one row. The
1307
- // affordance is the pointer cursor plus an underline on hover
1308
- // or focus, set HERE rather than left to the host stylesheet:
1309
- // a shell that resets anchors would otherwise drop it silently.
1310
- style: {
1311
- color: "inherit",
1312
- textDecoration: linkHover ? "underline" : "none",
1313
- cursor: "pointer"
1314
- },
1315
- // focus joins hover — the token is keyboard-reachable now, and a
1316
- // keyboard user needs the same "this is a link" signal
1317
- onPointerEnter: () => { setHovered(true); setLinkHover(true); },
1318
- onPointerLeave: () => setLinkHover(false),
1319
- onFocus: () => { setHovered(true); setLinkHover(true); },
1320
- onBlur: () => setLinkHover(false),
1321
- children: prToken
1322
- });
1323
- // always wrapped (see the branch): while the commits are in flight the
1324
- // label names the PR state the base response already carries
1325
- const pending = detail === void 0 || detail === null;
1326
- return withTooltip(token, () => react_jsx_runtime.jsx(PrCommitsList, { info: detail ?? info, pending }), 480);
1327
- })();
1328
- const chip = react_jsx_runtime.jsxs("span", {
1472
+ }
1473
+ return chipAnchor({
1474
+ href: prUrl,
1475
+ ariaLabel: prTokenLabel(info) + ", opens on GitHub in a new tab",
1476
+ underline: hoveredLink === "pr",
1477
+ onPointerEnter: () => setHoveredLink("pr"),
1478
+ onPointerLeave: () => setHoveredLink(null),
1479
+ onFocus: () => setHoveredLink("pr"),
1480
+ onBlur: () => setHoveredLink(null),
1481
+ children: prToken
1482
+ });
1483
+ }
1484
+
1485
+ /** The chip row itself: leading cluster, operation text, count and PR token. */
1486
+ function chipAssemble(leadingHover, text, countHover, prTokenHover) {
1487
+ return react_jsx_runtime.jsxs("span", {
1329
1488
  style: {
1330
1489
  display: "inline-flex",
1331
1490
  alignItems: "center",
1332
1491
  flex: "none",
1333
- // 6px, not 4px: the PR token leads with a space, and CSS drops
1334
- // leading whitespace at the start of a flex item, so 4px read as no
1335
- // separation at all between the branch and `PR#…`
1492
+ // 6px, not 4px: the PR token leads with a space, and CSS drops leading
1493
+ // whitespace at the start of a flex item, so 4px read as no separation
1494
+ // at all between the branch and `PR#…`
1336
1495
  gap: "6px",
1337
1496
  color: "var(--dsw-alias-label-secondary, #5b6570)",
1338
1497
  fontSize: "12px",
@@ -1340,92 +1499,57 @@ window.__ModuleLoader__.load({
1340
1499
  whiteSpace: "nowrap",
1341
1500
  cursor: "default"
1342
1501
  },
1343
- // the long-form explanation, on hover; the inline seam-notice below is
1344
- // the visible channel (its text is the short form, its title this).
1345
- // null until the delayed check in apply() proves the seam never
1346
- // appeared.
1347
- title: seamHint === null ? undefined : seamHint,
1348
1502
  children: [
1349
- // The hover card belongs to the STATUS SYMBOL, not the badge: the
1350
- // circle is the "what does this colour mean" element, and a card
1351
- // opening sideways from a hover on the branch text got in the way of
1352
- // the very pull-down this row now carries. The detail fetch
1353
- // (enabled: hovered) follows the same pointer, so nothing pays for
1354
- // extras the card no longer shows. The seam-absent native title
1355
- // stays on the whole chip — it is about the badge, not the mark.
1356
- hoverableMark,
1357
-
1358
- branchHover,
1359
- react_jsx_runtime.jsx("span", { key: "text", children: text }),
1503
+ leadingHover,
1504
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
1360
1505
  countHover,
1361
- prTokenHover,
1362
- /**
1363
- * The seam-absent notice: the market-install notification for the
1364
- * state a blocked postinstall leaves behind — chip working, sidebar
1365
- * rows structurally off, nothing patched, nothing to restart INTO.
1366
- * It is only ever TRUE (seamHint is set 5s after boot, only after
1367
- * the composed graph is given its chance), and it is self-resolving:
1368
- * apply + restart makes the seam declare, and this never renders
1369
- * again. That is what distinguishes it from the old restart notice,
1370
- * which outlived the restart it asked for. Dismissal is a session
1371
- * value (see seamNoticeDismissed), so the × means "stop asking this
1372
- * boot", never "stop asking forever".
1373
- */
1374
- seamNotice === null
1375
- ? null
1376
- : react_jsx_runtime.jsxs(
1377
- "span",
1378
- {
1379
- style: {
1380
- display: "inline-flex",
1381
- alignItems: "center",
1382
- gap: "6px",
1383
- paddingLeft: "6px",
1384
- borderLeft: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
1385
- whiteSpace: "normal",
1386
- maxWidth: "320px"
1387
- },
1388
- children: [
1389
- react_jsx_runtime.jsx(
1390
- "span",
1391
- {
1392
- title: seamHint,
1393
- children: "sidebar badges off — run `npx dsh-git-badge apply`"
1394
- },
1395
- "explain"
1396
- ),
1397
- react_jsx_runtime.jsx(
1398
- "button",
1399
- {
1400
- "aria-label": "Dismiss the sidebar-badges notice (returns next boot while the seam is absent)",
1401
- onClick: () => {
1402
- seamNoticeDismissed = true;
1403
- bumpNotice((n) => n + 1);
1404
- },
1405
- style: {
1406
- cursor: "pointer",
1407
- color: "inherit",
1408
- background: "none",
1409
- border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
1410
- borderRadius: "4px",
1411
- fontSize: "11px",
1412
- lineHeight: "18px",
1413
- padding: "0 6px",
1414
- flex: "none"
1415
- },
1416
- children: "×"
1417
- },
1418
- "dismiss"
1419
- )
1420
- ]
1421
- },
1422
- "seam-notice"
1423
- ),
1506
+ prTokenHover
1424
1507
  ]
1425
1508
  });
1426
- // No Tooltip primitive (a shell that does not seed it): the mark renders
1427
- // bare and the badge still works — the card is an enhancement, the
1428
- // badge is the feature.
1509
+ }
1510
+
1511
+ /**
1512
+ * Chip line in the input row: git state of the workspace the CURRENT
1513
+ * conversation is attached to. It targets the session id and lets the node
1514
+ * half resolve the workspace, so this surface needs no `workspaces`
1515
+ * service, no cwd and no path plumbing at all. `conversation.input.left`
1516
+ * is an upstream additive slot, so it works on every install, and it stays
1517
+ * useful next to the session row because it is context-anchored ("where am
1518
+ * I") rather than surveying.
1519
+ *
1520
+ * This is the ONLY surface that asks the node half for the extras (`pr=1`
1521
+ * always, `detail=1` on hover), because it is the only one that renders
1522
+ * them — see targetQuery.
1523
+ */
1524
+ function ComposerGitChip({ sessionId }) {
1525
+ const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1526
+ // the PR/CI token is always on the chip, so its fetch is not gated
1527
+ const info = useGitStatus(target, { pr: true });
1528
+ // The card's extras cost a `log -10` plus a stash list, so they are fetched
1529
+ // only once a pointer actually RESTS on the chip. A refresh fires on every
1530
+ // file edit, so eagerly asking would add both invocations to every refresh.
1531
+ const [hovered, setHovered] = react.useState(false);
1532
+ // Which of the chip's two links is hovered/focused — ONE enum, not two
1533
+ // booleans: "branch" | "pr" | null. The anchor underlines only when its
1534
+ // own value matches, so hovering one link never underlines the other.
1535
+ const [hoveredLink, setHoveredLink] = react.useState(null);
1536
+ const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
1537
+ if (info === void 0 || info.git !== true) return null;
1538
+
1539
+ const tokenGlyph = chipGlyphNode(info);
1540
+ // The branch name links to the compare view (base...branch). Vouched http(s)
1541
+ // only; new tab, never navigating the conversation away.
1542
+ const isBranchLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1543
+ const branchInner = chipBranchInner(info, detail, isBranchLink, hoveredLink, setHovered, setHoveredLink);
1544
+ // The Tooltip's CHILD must not change element type once the detail payload
1545
+ // lands, so the cluster span is the stable hover surface. ALWAYS wrapped.
1546
+ const leadingHover = chipLeadingHover(info, detail, tokenGlyph, branchInner, isBranchLink, setHovered);
1547
+ const text = formatOperationToken(info);
1548
+ const countHover = chipCountHover(info, detail, setHovered);
1549
+ const prTokenHover = chipPrTokenHover(info, hoveredLink, setHoveredLink);
1550
+ const chip = chipAssemble(leadingHover, text, countHover, prTokenHover);
1551
+ // No Tooltip primitive (a shell that does not seed it): the leading cluster
1552
+ // renders bare and the badge still works — the card is an enhancement.
1429
1553
  return chip;
1430
1554
  }
1431
1555
 
@@ -1435,7 +1559,7 @@ window.__ModuleLoader__.load({
1435
1559
  // node half resolves the workspace, so the client never needs a service
1436
1560
  // lookup. Fewer declared services also means fewer ways to fail to load.
1437
1561
  /** The built-in ranking order — what "Reset to default" restores. */
1438
- const DEFAULT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
1562
+ const DEFAULT_ORDER = ["operation", "unmerged", "conflict", "review", "sync", "publish", "merge", "commit", "checks"];
1439
1563
 
1440
1564
  /**
1441
1565
  * Move one category within the ranking order — the card's only reorder
@@ -1455,6 +1579,8 @@ window.__ModuleLoader__.load({
1455
1579
  const CATEGORY_LABELS = {
1456
1580
  operation: ["operation", "resume a paused merge/rebase"],
1457
1581
  unmerged: ["unmerged", "resolve conflict markers"],
1582
+ conflict: ["conflict", "a pull request that conflicts with its base"],
1583
+ review: ["review", "a review to address, or a review to wait for"],
1458
1584
  sync: ["sync", "pull, or rebase + confirmed force-push"],
1459
1585
  publish: ["publish", "push local commits"],
1460
1586
  merge: ["merge", "a merge-ready pull request"],
@@ -1590,12 +1716,12 @@ window.__ModuleLoader__.load({
1590
1716
  });
1591
1717
  }
1592
1718
 
1593
- const inject = ["slots", "inputTriggers"];
1719
+ const inject = ["slots"];
1594
1720
 
1595
1721
  /**
1596
- * Register the badge into the seams. The seam owner hands each entry the row
1597
- * owner share as props; the row badge destructures `{ sessionId }` and
1598
- * deliberately ignores the cwd it may also be given.
1722
+ * Register the badge into upstream's own seats. The slot owner hands each
1723
+ * entry the row owner share as props; the row surfaces destructure
1724
+ * `{ sessionId }` and deliberately ignore the cwd they may also be given.
1599
1725
  */
1600
1726
  function apply(ctx) {
1601
1727
  // The /gh command contribution (the (+) commands menu): the state-aware
@@ -1810,33 +1936,31 @@ window.__ModuleLoader__.load({
1810
1936
  }
1811
1937
  }
1812
1938
 
1813
- // inject() re-evaluates when a seam's declaration appears, so boot
1814
- // order relative to the workspace browser does not matter. On an
1815
- // unpatched install the seam is never declared, so this callback never
1816
- // fires and the row badge simply never renders.
1817
- //
1818
- // The registration is reported from INSIDE the callback on purpose: a
1819
- // `spec()` check here at apply() time runs before the workspace browser
1820
- // declares the seam, so it reports the boot race rather than the
1821
- // outcome. That mistake made the old one-shot line claim "sidebar rows
1822
- // = off" while five row badges were rendering.
1823
- let rowsReported = false;
1824
- ctx.slots.inject("sidebar.workspaces.sessionRow", () => {
1825
- if (!rowsReported) {
1826
- rowsReported = true;
1827
- console.info("[dsh-git-badge] surfaces: sidebar session rows = on (seam present).");
1828
- }
1829
- return ctx.slots.register({
1830
- name: "sidebar.workspaces.sessionRow",
1831
- id: "git-badge-row"
1832
- }, SessionGitBadge);
1833
- });
1834
- // The row is ALREADY a HoverCard anchor upstream, so a badge that opened
1835
- // its own tooltip would nest two cards. This renders the provenance line
1939
+ // The row's token rides upstream's OWN `sidebar.session.row.leading`
1940
+ // seat: a 16px cell the workspace browser renders in the row's leading
1941
+ // cluster. No patch and no declaration to make.
1942
+ ctx.slots.inject("sidebar.session.row.leading", () => ctx.slots.register({
1943
+ name: "sidebar.session.row.leading",
1944
+ id: "git-badge-row-leading"
1945
+ }, LeadingTokenGlyph));
1946
+ // The seat above is mounted ONLY while the row's primary state is idle
1947
+ // (upstream's own contract). A row that owns a solid status dot — an
1948
+ // unread completion, or a pending interaction — keeps that cell to
1949
+ // itself, so the token glyph would be invisible exactly when the turn
1950
+ // that produced it just ended, until the session is opened. This strip is
1951
+ // ALWAYS mounted (hidden by CSS, not unmounted), so a marker rides it and
1952
+ // the stylesheet paints the token over the dot. The rule stays honest: no
1953
+ // upstream byte changes, and the cell still belongs to upstream.
1954
+ ctx.slots.inject("sidebar.workspaces.session.row.action", () => ctx.slots.register({
1955
+ name: "sidebar.workspaces.session.row.action",
1956
+ id: "git-badge-row-fallback",
1957
+ order: 300
1958
+ }, RowFallbackGlyph));
1959
+ ensureRowFallbackStyle();
1960
+ // The row is ALREADY a HoverCard anchor upstream, so a glyph that opened
1961
+ // its own tooltip would nest two cards. This renders the explanation
1836
1962
  // inside that card instead, through the hover seat upstream declares
1837
- // itself (sidebar.session.row.hover) — no seam patch and no request of
1838
- // its own. Upstream 0.2.0-rc.2 threads renderSlot into the card and adds
1839
- // the seat; before that this needed a patched sessionRow.detail seam.
1963
+ // itself (sidebar.session.row.hover) — no request of its own.
1840
1964
  ctx.slots.inject("sidebar.session.row.hover", () => ctx.slots.register({
1841
1965
  name: "sidebar.session.row.hover",
1842
1966
  id: "git-badge-row-detail"
@@ -1852,29 +1976,9 @@ window.__ModuleLoader__.load({
1852
1976
  id: "git-badge-chip",
1853
1977
  inject: (sessionId) => ({ sessionId })
1854
1978
  }, ComposerGitChip));
1855
- const seamDeclared = ctx.slots.spec("sidebar.workspaces.sessionRow") !== void 0;
1856
- console.info(
1857
- "[dsh-git-badge] surfaces: input chip = on; sidebar session rows = " +
1858
- (seamDeclared
1859
- ? "on (seam present)."
1860
- : // discoverability: the chip works everywhere, but the session rows
1861
- // need the seam patch, and on a market install nothing else says so.
1862
- // The patcher ships IN this package — no clone needed.
1863
- "off (seam absent) — run `npx dsh-git-badge apply` to add them, then restart dsh web.")
1864
- );
1865
- // The chip tooltip can only be set LATE. At apply() time the workspace
1866
- // browser may not have declared the seam yet (the boot race documented
1867
- // above), so claiming absence here would lie to patched installs for the
1868
- // whole session — the exact mistake that made the old one-shot line
1869
- // claim "sidebar rows = off" while five row badges were rendering. Give
1870
- // the composed graph a beat to settle; only a seam that is STILL absent
1871
- // earns the hint, and every chip render after that carries it.
1872
- setTimeout(() => {
1873
- if (rowsReported) return; // seam present; badges rendered — nothing to hint about
1874
- if (ctx.slots.spec("sidebar.workspaces.sessionRow") !== void 0) return;
1875
- seamHint =
1876
- "Session-row badges are off: run `npx dsh-git-badge apply`, then restart dsh web. (Input-chip badges are unaffected.)";
1877
- }, 5000);
1979
+ // No seam, no apply step: the leading seat and the hover seat are both
1980
+ // upstream's own. Report the surfaces once, for diagnostics.
1981
+ console.info("[dsh-git-badge] surfaces: input chip = on; sidebar row token = on (upstream leading seat).");
1878
1982
  // The hover card depends on a primitive the SHELL seeds, not on anything
1879
1983
  // this plugin declares. Report the outcome rather than letting a missing
1880
1984
  // seed look like a missing feature: the try/catch above deliberately keeps
@@ -1893,7 +1997,7 @@ window.__ModuleLoader__.load({
1893
1997
  // Additive; the host reads apply/inject and ignores the rest. The suite
1894
1998
  // drives these to assert the REQUEST contract — which surface asks for the
1895
1999
  // expensive extras — without a browser, a fetch or a network.
1896
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, linkedWorktreesDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
2000
+ exports.__internals = { targetQuery, worktreeDetail, linkedWorktreesDetail, ghSkillActions, cleanGate, moveCategory, fallbackGlyph, glyphMaskDataUri, syncRowFallback, clearFallbackMark, ROW_FALLBACK_CSS, ROW_FALLBACK_DOT };
1897
2001
  return module.exports;
1898
2002
  }
1899
2003
  });