dsh-git-badge 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -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
+ }
289
+
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
+ };
347
301
 
348
- /** ` PR#142 ✗` (plus ` draft`), or "" when there is no PR to report. */
349
- function formatPrToken(info) {
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.
447
+ *
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.
579
453
  *
580
- * The FILL is not set here: it is the action's own colour at low alpha, which
581
- * only SessionGitBadge can build.
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,153 @@ 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.
567
+ *
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.
634
581
  *
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.
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
+ style: { ...META_STYLE, ...(colour === void 0 ? {} : { color: colour }), ...GLYPH_TEXT, display: "inline-flex", alignItems: "center" },
599
+ children: actionGlyphNode(token)
600
+ });
655
601
  }
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
- });
602
+ return null;
603
+ }
604
+
605
+ const CARD_CONTAINER = {
606
+ display: "flex",
607
+ flexDirection: "column",
608
+ gap: "4px",
609
+ fontSize: "12px",
610
+ lineHeight: "18px",
611
+ // Auto-width: the card fits its widest row instead of a guessed
612
+ // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
613
+ // cap or IT becomes the clamp.
614
+ width: "max-content",
615
+ maxWidth: "600px"
616
+ };
617
+ const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
618
+ const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
619
+ const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere", whiteSpace: "normal" };
620
+ // The chip card's HEADER: the action sentence as a heading, not a labelled
621
+ // row. `fontWeight` is also the hook the suite uses to find it.
622
+ const CHIP_HEADER = { display: "flex", alignItems: "baseline", gap: "6px", fontWeight: 600 };
623
+ // A section rule between the checkout rows and the branch rows. The label
624
+ // makes the split explicit instead of leaving `state`/`base` to look like
625
+ // more checkout detail.
626
+ const CARD_DIVIDER = {
627
+ marginTop: "4px",
628
+ paddingTop: "6px",
629
+ borderTop: ".5px solid var(--dsw-alias-border-l4, #e4e8eb)",
630
+ color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
631
+ fontSize: "10px",
632
+ lineHeight: "14px",
633
+ letterSpacing: ".08em",
634
+ textTransform: "uppercase"
635
+ };
636
+
637
+ /** One label/value row — the shape every hover body shares. */
638
+ function cardRow(label, value) {
639
+ return react_jsx_runtime.jsxs("div", {
640
+ style: CARD_ROW,
641
+ children: [
642
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
643
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
644
+ ]
645
+ }, label);
646
+ }
647
+
648
+ /** Push one label/value row unless the value is empty. */
649
+ function addRow(rows, label, value) {
650
+ if (value === void 0 || value === null || value === "") return;
651
+ rows.push(cardRow(label, value));
667
652
  }
668
653
 
669
654
  /**
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.
655
+ * The action SENTENCE as a raw value. SHARED by the session-row card
656
+ * (actionRow wraps it in the card label/value shape) and the chip card
657
+ * (actionHeader wraps it in a heading), so the two cannot describe the icon
658
+ * differently. Returns undefined when the checkout has no token.
674
659
  *
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.
660
+ * The glyph is DECORATION here: the word follows it, so `aria-hidden` keeps
661
+ * the accessible name the bare action.
662
+ */
663
+ function actionRow(info) {
664
+ const token = info?.rowToken;
665
+ if (typeof token !== "string" || token === "") return void 0;
666
+ const why = typeof info.rowTokenWhy === "string" && info.rowTokenWhy !== "" ? info.rowTokenWhy : void 0;
667
+ const colour = SEVERITY_FILL[info.rowSeverity];
668
+ const value = [];
669
+ if (DRAWN_ICONS[token] !== void 0 || ACTION_GLYPH[token] !== void 0) {
670
+ value.push(react_jsx_runtime.jsx("span", {
671
+ "aria-hidden": true,
672
+ style: { color: colour === void 0 ? META_STYLE.color : colour, fontWeight: 700, display: "inline-flex", alignItems: "center" },
673
+ children: actionGlyphNode(token)
674
+ }, "action-glyph"));
675
+ value.push(" ");
676
+ }
677
+ value.push(token + (why === void 0 ? "" : " \u2014 " + why));
678
+ return value;
679
+ }
680
+
681
+ /**
682
+ * The chip card's HEADER: the same sentence actionRow produces, rendered as a
683
+ * heading so the checkout's verdict leads the card instead of sitting in a
684
+ * labelled row. It calls actionRow, so the two surfaces cannot drift.
685
+ */
686
+ function actionHeader(info) {
687
+ const value = actionRow(info);
688
+ if (value === void 0) return void 0;
689
+ return react_jsx_runtime.jsx("div", { style: CHIP_HEADER, children: value }, "action-header");
690
+ }
691
+
692
+ /**
693
+ * Session-row hover card — everything the action glyph cannot say: what the
694
+ * action means and why, which checkout the row's status describes, and which
695
+ * pull request it refers to. It renders through the upstream
696
+ * sidebar.session.row.hover seat, inside the card upstream already opens for
697
+ * a row.
698
+ *
699
+ * LABEL/VALUE ROWS, the shape the chip card uses. The `action` row comes from
700
+ * the shared actionRow() helper, so the two cards cannot describe the icon
701
+ * differently; `checkout` and `pull request` follow.
702
+ *
703
+ * This matters MORE now that the row shows only a glyph: `merge` with no way
704
+ * to see merge WHAT would be a nag rather than a hint, so the card renders
705
+ * whenever there is something to name — the row's action, a worktree, a pull
706
+ * request, or any combination.
678
707
  *
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`.
708
+ * It makes no request of its own: the same `{ pr: true }` query as the
709
+ * leading glyph resolves to the same cache key, so a card that mounts for
710
+ * every row still costs one fetch per row, and a cold cache is served by
711
+ * the glyph's own in-flight request.
683
712
  *
684
713
  * Only a WORKTREE's checkout is spelled out. For a main checkout the branch is
685
714
  * the ordinary case and repeating it on every session's card would be noise;
@@ -688,18 +717,35 @@ window.__ModuleLoader__.load({
688
717
  function SessionGitDetail({ sessionId }) {
689
718
  const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
690
719
  if (info === void 0 || info.git !== true) return null;
691
- const bits = [];
720
+ const rows = [];
721
+ const add = (label, value) => addRow(rows, label, value);
722
+ // The HEADER, the shape the chip card leads with: the same sentence from
723
+ // the same actionRow, so the two cards cannot describe the icon
724
+ // differently, and the sentence gains the 62px label column's width.
725
+ const header = actionHeader(info);
726
+ if (header !== void 0) rows.push(header);
692
727
  if (info.isWorktree === true) {
728
+ // WHICH tree, not just that there is one: the row's glyph cannot say
729
+ // it, and the branch is ordinary for a main checkout.
693
730
  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 "));
731
+ add("checkout", name === "" ? String(info.branch) : name + " on " + info.branch);
699
732
  }
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 ") });
733
+ // The dirty breakdown, so a higher-ranked token cannot hide uncommitted
734
+ // work. formatFileBreakdown says "clean" when there is none; a clean row
735
+ // keeps its short card.
736
+ const changes = formatFileBreakdown(info);
737
+ if (changes !== "clean") add("changes", changes);
738
+ add("pull request", formatPrDetail(info.pr));
739
+ if (rows.length === 0) return null;
740
+ // The row's hover card is upstream's FIXED-WIDTH box: HoverCard.module.css
741
+ // pins `.card` to 244px. `width: max-content` (CARD_CONTAINER) would
742
+ // overflow it, so the stack FILLS the box instead and the value cells wrap
743
+ // inside it. The chip card is different — its Tooltip sizes to the content,
744
+ // so it keeps max-content.
745
+ return react_jsx_runtime.jsx("div", {
746
+ style: { ...CARD_CONTAINER, width: "100%", maxWidth: "100%" },
747
+ children: rows
748
+ });
703
749
  }
704
750
 
705
751
  /**
@@ -722,7 +768,7 @@ window.__ModuleLoader__.load({
722
768
  /** The PR's full state as text, for the card: "#142 · checks failing · draft · review approved". */
723
769
  function formatPrDetail(pr) {
724
770
  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];
771
+ const state = prStateLabel(pr);
726
772
  const bits = ["#" + pr.number, state];
727
773
  if (pr.draft === true) bits.push("draft");
728
774
  if (pr.review !== void 0) bits.push(String(pr.review).toLowerCase().replace(/_/g, " "));
@@ -760,6 +806,24 @@ window.__ModuleLoader__.load({
760
806
  return name;
761
807
  }
762
808
 
809
+ /**
810
+ * The `worktrees` row: how many LINKED worktrees this repository has while
811
+ * the badge follows none of them.
812
+ *
813
+ * It earns its place on one thing only — the failure it makes visible. A
814
+ * registration is written outside the repository, so a file sandbox or a
815
+ * read-only home can refuse it. When that happens the badge silently keeps
816
+ * describing the session's own checkout, and the agent's work sits in a tree
817
+ * the UI never names. That state is indistinguishable from a session with
818
+ * nothing to follow, so the card is the only place it can be said. The node
819
+ * half computes it under `detail=1` only, so nothing at rest pays for it.
820
+ */
821
+ function linkedWorktreesDetail(info) {
822
+ const linked = info?.linkedWorktrees;
823
+ if (typeof linked !== "number" || linked <= 0) return void 0;
824
+ return linked + " linked \u00B7 none registered \u2014 npx dsh-git-badge checkout <path>";
825
+ }
826
+
763
827
  /**
764
828
  * ONE commit line, shared by the branch hover and the PR token's hover so
765
829
  * the two cannot drift apart again: a fixed-width monospace hash cell, so
@@ -792,50 +856,6 @@ window.__ModuleLoader__.load({
792
856
  });
793
857
  }
794
858
 
795
- /**
796
- * The PR token's own hover body — the commits IN THIS PULL REQUEST
797
- * (`base..HEAD`), and nothing else: the token already says the number and
798
- * CI state, so the one question left is "what is in it?". Same line format
799
- * as the branch hover's commit list (see CommitLine — one renderer, so the
800
- * columns line up in both by construction), and the card's own width
801
- * behaviour: full-width lines, nowrap, ellipsis.
802
- */
803
- function PrCommitsList({ info, pending = false }) {
804
- const commits = info?.prCommits;
805
- if (!Array.isArray(commits) || commits.length === 0) {
806
- // `pending`: the detail payload has not landed yet, so "no commits"
807
- // would be a false negative — name the pull request the token points
808
- // at instead, the way the mark's card degrades to the base response.
809
- if (pending) {
810
- return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatPrDetail(info?.pr) ?? "loading\u2026" });
811
- }
812
- return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
813
- }
814
- return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
815
- react_jsx_runtime.jsxs("div", {
816
- style: { display: "flex", flexDirection: "column", gap: "2px", minWidth: 0 },
817
- children: [
818
- ...commits.map((commit) => react_jsx_runtime.jsx(CommitLine, { commit }, commit.hash)),
819
- (typeof info.prCommitsTotal === "number" && info.prCommitsTotal > commits.length
820
- ? "\u2026 and " + (info.prCommitsTotal - commits.length) + " more"
821
- : null)
822
- ]
823
- })
824
- ] });
825
- }
826
-
827
- const CARD_CONTAINER = {
828
- display: "flex",
829
- flexDirection: "column",
830
- gap: "4px",
831
- fontSize: "12px",
832
- lineHeight: "18px",
833
- // Auto-width: the card fits its widest row instead of a guessed
834
- // constant, capped at 600px. The Tooltip's maxWidth must stay >= the
835
- // cap or IT becomes the clamp.
836
- width: "max-content",
837
- maxWidth: "600px"
838
- };
839
859
  /**
840
860
  * The ✎n count's own hover body — the changed-file lists and NOTHING
841
861
  * else: the full card stays on the status mark, and this tooltip answers
@@ -907,35 +927,31 @@ window.__ModuleLoader__.load({
907
927
  }
908
928
 
909
929
  /**
910
- * The branch name's own hover body — the branch's LINEAGE, and it ALWAYS
911
- * renders once the lazy detail payload has landed. Rows: upstream, sync
912
- * in words, the branch's last 10 commits (WHAT THIS BRANCH HAS, full
913
- * stop — no base-relative filtering, which empties on every push or
914
- * merge). The pull request has no row here — the chip's own PR token
915
- * already carries number, CI state and the link.
930
+ * The branch LINEAGE rows, for the chip's one card. The state row always
931
+ * renders (the pull-request verdict, or the base-branch note); the base row
932
+ * follows when the counts are known; then the branch's last 10 commits
933
+ * (WHAT THIS BRANCH HAS, full stop — no base-relative filtering, which
934
+ * empties on every push or merge). The pull request has no row here — the
935
+ * chip's own PR token already carries number, CI state and the link.
916
936
  */
917
- function BranchLineage({ info }) {
937
+ function lineageRows(info) {
918
938
  const rows = [];
919
- const add = (label, value) => {
920
- if (value === void 0 || value === null || value === "") return;
921
- rows.push(cardRow(label, value));
922
- };
923
- // ONLY the merge axis, and it ALWAYS renders. The old upstream and sync
924
- // rows spoke about the push axis (the branch's own remote), which the
925
- // merge row subsumes: "1 ahead, 0 behind main" is the same fact stated
926
- // against the branch that matters, in words rather than arrows. A row
927
- // that can be absent is a row the reader has to reconstruct.
939
+ const add = (label, value) => addRow(rows, label, value);
940
+ // The STATE row always renders. The old upstream and sync rows spoke
941
+ // about the push axis (the branch's own remote), which the state row
942
+ // subsumes: the merge verdict is the fact that matters. The base counts
943
+ // stay a SEPARATE row below, so a reader never reconstructs either.
928
944
  const pr = info?.pr;
929
- const mergeParts = [];
945
+ const stateParts = [];
930
946
  if (pr !== void 0 && pr !== null && pr.number !== void 0) {
931
- if (pr.draft === true) mergeParts.push("blocked: draft");
947
+ if (pr.draft === true) stateParts.push("blocked: draft");
932
948
  else if (pr.mergeState === "BLOCKED") {
933
- mergeParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
949
+ stateParts.push(pr.review === "REVIEW_REQUIRED" ? "blocked: review required"
934
950
  : pr.state === "failing" ? "blocked: checks failing"
935
951
  : "blocked");
936
- } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
937
- else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
938
- else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
952
+ } else if (pr.state === "failing") stateParts.push("blocked: checks failing");
953
+ else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) stateParts.push("ready to merge");
954
+ else if (pr.review === "REVIEW_REQUIRED") stateParts.push("blocked: review required");
939
955
  } else if (info?.defaultBranch === true) {
940
956
  // The default branch is the BASE, not a branch with a missing PR:
941
957
  // pull requests merge INTO it and none is ever opened FROM it, so
@@ -943,18 +959,21 @@ window.__ModuleLoader__.load({
943
959
  // exist. The node half computes `defaultBranch` for exactly this
944
960
  // suppression (see the comment where it is set in lib/index.js);
945
961
  // until now only the action rules consulted it, not this row.
946
- mergeParts.push("base branch \u00B7 pull requests merge into this");
962
+ stateParts.push("base branch \u00B7 pull requests merge into this");
947
963
  } else {
948
- mergeParts.push("no pull request");
964
+ stateParts.push("no pull request");
949
965
  }
950
- // The counts are this branch's standing against its BASE, so on the
951
- // base itself they are self-referential ("N ahead, M behind main" while
952
- // ON main) and the divergence they would state is already the chip's own
953
- // \u2191a \u2193b token. Suppress them on the default branch, with or without a PR.
966
+ // The state row states the PR verdict and nothing else.
967
+ add("state", stateParts.join(" \u00B7 "));
968
+ // The counts are this branch's standing against its BASE, so on the base
969
+ // itself they are self-referential ("N ahead, M behind main" while ON
970
+ // 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
972
+ // PR. A SEPARATE row from state: whether the PR can merge and how far the
973
+ // branch is from its base are two facts, so neither hides the other.
954
974
  if (info?.defaultBranch !== true && (info?.mainAhead !== void 0 || info?.mainBehind !== void 0)) {
955
- mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
975
+ add("base", (info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind " + baseNameFor(info));
956
976
  }
957
- add("merge", mergeParts.join(" \u00B7 "));
958
977
  const commits = info?.branchCommits;
959
978
  if (Array.isArray(commits) && commits.length > 0) {
960
979
  // Full-width block, not the label/value two-column row: commit lines
@@ -975,13 +994,9 @@ window.__ModuleLoader__.load({
975
994
  }
976
995
  // NO pull-request row: the chip's own PR token (number, CI state, link)
977
996
  // carries it — a second rendering two pixels away is pure duplication.
978
- return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
997
+ return rows;
979
998
  }
980
999
 
981
- const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
982
- const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
983
- const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
984
-
985
1000
  // The host's plugin-card chrome, reproduced inline: its CSS module is not
986
1001
  // exported to plugins, so these values mirror PluginCard.module.css so our
987
1002
  // card sits in the same list as its siblings.
@@ -1027,62 +1042,84 @@ window.__ModuleLoader__.load({
1027
1042
  flex: "none"
1028
1043
  };
1029
1044
 
1030
- /** One label/value row — the shape every hover body shares. */
1031
- function cardRow(label, value) {
1032
- return react_jsx_runtime.jsxs("div", {
1033
- style: CARD_ROW,
1034
- children: [
1035
- react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
1036
- react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
1037
- ]
1038
- }, label);
1039
- }
1040
-
1041
-
1042
1045
  /**
1043
- * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
1044
- * over fields the status response already carries, plus the `detail=1`
1045
- * additions (recent commits, stash) when they have arrived. Everything is
1046
- * spelled out in text, so no fact here depends on a colour or a glyph.
1046
+ * The checkout's verdict rows, for the chip's one card — INPUT CHIP ONLY.
1047
+ * Pure presentation over fields the status response already carries, plus
1048
+ * the `detail=1` additions (recent commits, stash) when they have arrived.
1049
+ * Everything is spelled out in text, so no fact here depends on a colour or
1050
+ * a glyph.
1047
1051
  */
1048
- function HoverCard({ info, detail }) {
1052
+ function statusRows(info, detail) {
1049
1053
  // the detail payload is a superset of the plain one, so it wins when it
1050
1054
  // has landed; until then the card still shows the base facts
1051
1055
  const data = detail === void 0 ? info : detail;
1052
1056
  const rows = [];
1053
- const add = (label, value) => {
1054
- if (value === void 0 || value === null || value === "") return;
1055
- rows.push(cardRow(label, value));
1056
- };
1057
- // The "action" row — the top line, in the card's ordinary row layout:
1058
- // the SERVER's ranked suggestion, and nothing else. The client's
1059
- // extras (secondary picker entries) must never promote themselves into
1060
- // this line: when the server says there is nothing to do — clean and
1061
- // synced — the row is omitted, and "no suggestion" stays a suggestion.
1062
- // Plain text, no affordance: the actionable surface is the (+) menu's
1063
- // /gh picker, and this row is the read-only pointer to it.
1057
+ const add = (label, value) => addRow(rows, label, value);
1058
+ // The ACTION header, first: the SAME sentence the session-row card renders,
1059
+ // from the same helper, so the chip card explains the icon the row shows.
1060
+ // It is a heading, not a labelled row, so the verdict leads the card.
1061
+ const header = actionHeader(data);
1062
+ if (header !== void 0) rows.push(header);
1063
+ // The "next step" row — the SERVER's ranked suggestion, and nothing else. The
1064
+ // client's extras (secondary picker entries) must never promote
1065
+ // themselves into this line: when the server says there is nothing to do
1066
+ // — clean and synced — the row is omitted, and "no suggestion" stays a
1067
+ // suggestion. Plain text, no affordance: the actionable surface is the
1068
+ // (+) menu's /gh picker, and this row is the read-only pointer to it.
1064
1069
  const topAction = data.next;
1065
1070
  if (topAction !== void 0) {
1066
1071
  // git-comment convention: the invocation, two spaces, then "# "
1067
- // plus what it does
1068
- add("action", "/gh " + topAction.args + " # " + topAction.what);
1072
+ // plus what it does. The marker makes the one actionable line findable
1073
+ // among the detail rows.
1074
+ add("next step", "\u25B8 /gh " + topAction.args + " # " + topAction.what);
1069
1075
  }
1070
1076
  // The branch lineage rows (branch, upstream, sync, pull request) moved
1071
1077
  // to the branch NAME's hover — the card is the checkout's verdict, not
1072
1078
  // the branch's CV. The branch name is also the chip's own text, so a
1073
1079
  // card row for it was pure repetition.
1074
- add("files", formatFileBreakdown(info));
1075
- add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
1080
+ add("changes", formatFileBreakdown(data));
1081
+ add("operation", data.operation === void 0 || data.operation === null ? void 0 : String(data.operation));
1076
1082
  // WHICH checkout the badge describes, when it is a worktree. The chip shows
1077
1083
  // the branch alone, so this row is the only place the tree is named.
1078
1084
  add("worktree", worktreeDetail(data));
1085
+ // The linked-but-unregistered state — the on-screen difference between
1086
+ // "nothing to follow" and "the registration could not be written". Rides
1087
+ // the detail payload, so it appears once the hover has fetched, exactly
1088
+ // like the `checkout` row below it.
1089
+ add("worktrees", linkedWorktreesDetail(data));
1079
1090
  // The conversation's OWN directory, present only when the badge is
1080
1091
  // describing a followed worktree — the chip's branch and counts are the
1081
1092
  // tree's in that case, so this is where the checkout's own state stays
1082
- // visible. Absent until the `detail=1` response lands, like commits/stash.
1083
- if (data.worktreeFollowed === true) add("checkout", formatCheckoutDetail(data.checkout));
1093
+ // visible. Labelled `own checkout` because `checkout` now names the
1094
+ // checkout a session-row card describes. Absent until the `detail=1`
1095
+ // response lands, like commits/stash.
1096
+ if (data.worktreeFollowed === true) add("own checkout", formatCheckoutDetail(data.checkout));
1084
1097
  add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
1085
- return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
1098
+ return rows;
1099
+ }
1100
+
1101
+ /**
1102
+ * The chip's ONE card, behind the leading cluster (the action glyph and the
1103
+ * branch name together). It stacks the checkout's verdict rows over the
1104
+ * branch's lineage rows, so one hover answers both "what is this checkout
1105
+ * doing" and "what is on this branch".
1106
+ *
1107
+ * One card, not two: the glyph and the branch are one target, and the branch
1108
+ * is the element that always exists — so a clean checkout, which has no
1109
+ * glyph, still opens the card from its branch name.
1110
+ */
1111
+ function ChipCard({ info, detail }) {
1112
+ const data = detail === void 0 ? info : detail;
1113
+ const rows = statusRows(info, detail);
1114
+ const lineage = lineageRows(data);
1115
+ return react_jsx_runtime.jsx("div", {
1116
+ style: CARD_CONTAINER,
1117
+ children: [
1118
+ ...rows,
1119
+ lineage.length === 0 ? null : react_jsx_runtime.jsx("div", { style: CARD_DIVIDER, children: "branch" }, "branch-divider"),
1120
+ ...lineage
1121
+ ]
1122
+ });
1086
1123
  }
1087
1124
 
1088
1125
  //#region composer chip (upstream additive surface: conversation.input.left)
@@ -1105,211 +1142,152 @@ window.__ModuleLoader__.load({
1105
1142
  }
1106
1143
 
1107
1144
  /**
1108
- * Chip line in the input row: git state of the workspace the CURRENT
1109
- * conversation is attached to. It targets the session id and lets the node
1110
- * half resolve the workspace, so this surface needs no `workspaces`
1111
- * service, no cwd and no path plumbing at all. Works on unpatched installs
1112
- * (conversation.input.left is an upstream additive slot), and stays useful
1113
- * next to the sidebar rows because it is context-anchored ("where am I")
1114
- * rather than surveying.
1115
- *
1116
- * This is the ONLY surface that asks the node half for the extras (`pr=1`
1117
- * always, `detail=1` on hover), because it is the only one that renders
1118
- * them — see targetQuery.
1145
+ * The chip's leading action glyph, or null when the checkout needs none.
1146
+ * The SAME token the sidebar row draws, in the node half's severity colour.
1147
+ * No token means NO GLYPH: a clean, synced checkout has no action to triage,
1148
+ * so it shows no icon rather than a decorative dot.
1119
1149
  */
1150
+ function chipGlyphNode(info) {
1151
+ const token = info.rowToken;
1152
+ if (typeof token !== "string" || token === "") return null;
1153
+ return react_jsx_runtime.jsx("span", {
1154
+ key: "glyph",
1155
+ role: "img",
1156
+ "aria-label": "git action: " + token,
1157
+ style: { color: SEVERITY_FILL[info.rowSeverity] ?? META_STYLE.color, flex: "none", ...GLYPH_TEXT, display: "inline-flex", alignItems: "center" },
1158
+ children: actionGlyphNode(token)
1159
+ });
1160
+ }
1161
+
1120
1162
  /**
1121
- * Set LATE (see apply) — never at apply() time, because the workspace
1122
- * browser may not have declared the seam yet and a hint set then would
1123
- * lie to patched installs for the whole session. Every chip render reads
1124
- * it, so it reaches the DOM on the next refresh after it is set.
1163
+ * The shared anchor for the chip's two links — the branch compare link and
1164
+ * the PR token. Both were near-identical `<a>` elements; this is the one
1165
+ * copy of target, rel, cursor and handler wiring, so they cannot drift.
1166
+ * `underline` is the hover/focus state and `children` is the label.
1125
1167
  */
1126
- let seamHint = null;
1168
+ function chipAnchor(props) {
1169
+ return react_jsx_runtime.jsx("a", {
1170
+ href: props.href,
1171
+ target: "_blank",
1172
+ rel: "noopener noreferrer",
1173
+ "aria-label": props.ariaLabel,
1174
+ onPointerEnter: props.onPointerEnter,
1175
+ onPointerLeave: props.onPointerLeave,
1176
+ onFocus: props.onFocus,
1177
+ onBlur: props.onBlur,
1178
+ style: {
1179
+ color: "inherit",
1180
+ textDecoration: props.underline ? "underline" : "none",
1181
+ cursor: "pointer"
1182
+ },
1183
+ children: props.children
1184
+ });
1185
+ }
1127
1186
 
1128
1187
  /**
1129
- * Dismissal of the seam-absent notice, deliberately MODULE-level rather
1130
- * than component state: a useState flag would un-dismiss when the chip
1131
- * remounts (a sidebar toggle, a route change), and the × the user just
1132
- * clicked would silently stop meaning anything. Session-scoped on
1133
- * purpose — it resets at the next boot, so a seam that is STILL absent
1134
- * asks again, while one fixed by apply + restart never nags at all.
1188
+ * The branch name, as a compare anchor when the detail payload vouches an
1189
+ * http(s) URL. The sync arrows ride WITH the branch: they are the branch's
1190
+ * standing against its upstream, so the same hover and the same link cover
1191
+ * them. The ✎n count keeps its own surface.
1135
1192
  */
1136
- let seamNoticeDismissed = false;
1137
-
1138
- function ComposerGitChip({ sessionId }) {
1139
- const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1140
- // the PR/CI token is always on the chip, so its fetch is not gated
1141
- const info = useGitStatus(target, { pr: true });
1142
- // The card's extras cost a `log -10` plus a stash list, so they are
1143
- // fetched only once a pointer actually RESTS on the chip, then kept
1144
- // fresh by the same SSE path. Eagerly asking would add both invocations
1145
- // to every refresh — and a refresh fires on every file edit.
1146
- const [hovered, setHovered] = react.useState(false);
1147
- // the PR token's own hover/focus state: it underlines on hover (and on
1148
- // focus) rather than at rest, and that underline is applied inline below
1149
- const [linkHover, setLinkHover] = react.useState(false);
1150
- // seamHint is module state set once, 5s after boot; dismissing it needs a
1151
- // re-render that no fetch will schedule, so the × bumps a counter here.
1152
- const [, bumpNotice] = react.useState(0);
1153
- const seamNotice = seamHint !== null && !seamNoticeDismissed ? seamHint : null;
1154
- // the branch link's hover underline — the PR token's pattern: the
1155
- // affordance appears on hover/focus, never at rest. Its OWN state, not
1156
- // the PR token's linkHover: two links sharing one flag would
1157
- // underline both when either is hovered.
1158
- const [branchLinkHover, setBranchLinkHover] = react.useState(false);
1159
- const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
1160
- if (info === void 0 || info.git !== true) return null;
1193
+ function chipBranchInner(info, detail, isBranchLink, hoveredLink, setHovered, setHoveredLink) {
1194
+ const sync = formatBaseSync(info);
1195
+ const branchLabel = info.branch + sync;
1196
+ if (!isBranchLink) return branchLabel;
1197
+ return chipAnchor({
1198
+ href: detail.compareUrl,
1199
+ ariaLabel: "Compare " + info.branch + " with " + baseNameFor(info) + " on GitHub" + (sync === "" ? "" : ", " + (info.mainAhead || 0) + " ahead and " + (info.mainBehind || 0) + " behind " + baseNameFor(info)),
1200
+ underline: hoveredLink === "branch",
1201
+ onPointerEnter: () => setHoveredLink("branch"),
1202
+ onPointerLeave: () => setHoveredLink(null),
1203
+ onFocus: () => { setHovered(true); setHoveredLink("branch"); },
1204
+ onBlur: () => setHoveredLink(null),
1205
+ children: branchLabel
1206
+ });
1207
+ }
1161
1208
 
1162
- // The status mark wrapped in the chip's hover card — the card's ONLY
1163
- // anchor. Pointer rest here (and only here) enables the detail=1 fetch,
1164
- // so the extras are paid for exactly when the card that can render them
1165
- // is about to open. Defined inside the component: it closes over the
1166
- // hovered state that gates the detail fetch.
1167
- const hoverableMark = withTooltip(
1209
+ /**
1210
+ * The LEADING CLUSTER — the action glyph (when the checkout needs one) and
1211
+ * the branch name — wrapped in the one hover card. It is the chip card's ONLY
1212
+ * anchor: pointer rest here enables the detail=1 fetch.
1213
+ */
1214
+ function chipLeadingHover(info, detail, tokenGlyph, branchInner, isBranchLink, setHovered) {
1215
+ return withTooltip(
1168
1216
  react_jsx_runtime.jsx("span", {
1169
1217
  onPointerEnter: () => setHovered(true),
1170
- style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
1171
- children: react_jsx_runtime.jsx(StatusMark, { info })
1218
+ style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", cursor: isBranchLink ? "pointer" : "default" },
1219
+ children: [tokenGlyph, react_jsx_runtime.jsx("span", { key: "branch", children: branchInner })]
1172
1220
  }),
1173
- // a function label keeps the card's element tree out of every render
1174
- // until the tooltip actually opens
1175
- () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1176
- 600 // matches the body cap: the tooltip must never be the clamp
1221
+ () => react_jsx_runtime.jsx(ChipCard, { info, detail }),
1222
+ 600
1177
1223
  );
1178
- // the mark is an element now rather than a leading glyph in the string, so
1179
- // the SAME StatusMark the sidebar row draws carries the status here too;
1180
- // the container's 4px gap supplies the space the emoji's own did
1181
- // The branch NAME is its own hover surface: the commits this branch adds
1182
- // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1183
- // the leading text (operation token) and the count keep their own
1184
- // surfaces. The wrapper is always mounted (see withTooltip); without the
1185
- // Tooltip primitive the name renders plain.
1186
- const branchHover = (() => {
1187
- // The branch name links to the compare view (base...branch) — the web
1188
- // page of exactly what the lineage tooltip lists. Vouched http(s)
1189
- // only (the node half rebuilt the URL from parsed parts); new tab,
1190
- // never navigating the conversation away.
1191
- const isLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1192
- // The sync arrows ride WITH the branch: they are the branch's standing
1193
- // against its upstream, so the same hover (lineage) and the same link
1194
- // (compare view, where the arrows are the diff) cover them. The ✎n
1195
- // count stays its own surface — it answers a different question.
1196
- const sync = formatSync(info);
1197
- const label = info.branch + sync;
1198
- // The Tooltip's CHILD must not change element type once the detail
1199
- // payload lands: the primitive opens on the child's mouseenter and has
1200
- // no `open` prop, so a node replaced under the pointer may never be
1201
- // re-entered. The outer span is the stable hover surface; only this
1202
- // inner node swaps (plain text -> anchor) as the compare URL arrives.
1203
- const inner = isLink
1204
- ? react_jsx_runtime.jsx("a", {
1205
- href: detail.compareUrl,
1206
- target: "_blank",
1207
- rel: "noopener noreferrer",
1208
- "aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
1209
- onPointerEnter: () => setBranchLinkHover(true),
1210
- onPointerLeave: () => setBranchLinkHover(false),
1211
- // focus joins hover: a keyboard user needs the same underline, and
1212
- // focus (unlike a pointer) must also start the detail fetch
1213
- onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1214
- onBlur: () => setBranchLinkHover(false),
1215
- style: {
1216
- color: "inherit",
1217
- textDecoration: branchLinkHover ? "underline" : "none",
1218
- cursor: "pointer"
1219
- },
1220
- children: label
1221
- })
1222
- : label;
1223
- const branch = react_jsx_runtime.jsx("span", {
1224
- onPointerEnter: () => setHovered(true),
1225
- style: { cursor: isLink ? "pointer" : "default" },
1226
- children: inner
1227
- });
1228
- // ALWAYS wrapped. A wrapper mounted only once the lazy detail arrives
1229
- // cannot open for the visit that fetched it (see the child-stability
1230
- // note above); until the payload lands the lineage degrades to the base
1231
- // response, the way the mark's own card already does.
1232
- return withTooltip(branch, () => react_jsx_runtime.jsx(BranchLineage, { info: detail ?? info }), 480);
1233
- })();
1234
- const text = formatOperationToken(info);
1235
- // The ✎n count is its OWN hover surface: a names-only tooltip (see
1236
- // UntrackedList). It shares the mark's lazy detail fetch — resting on
1237
- // either triggers it once — and degrades to a plain count on a shell
1238
- // without the Tooltip primitive.
1239
- const countHover = (() => {
1240
- // ✎n only: the sync arrows moved to the branch's surface above
1241
- const countText = formatFileCount(info);
1242
- const count = react_jsx_runtime.jsx("span", {
1243
- onPointerEnter: () => setHovered(true),
1224
+ }
1225
+
1226
+ /**
1227
+ * The ✎n count as its OWN hover surface: a names-only tooltip. It shares
1228
+ * the mark's lazy detail fetch and degrades to a plain count without the
1229
+ * Tooltip primitive.
1230
+ */
1231
+ function chipCountHover(info, detail, setHovered) {
1232
+ const countText = formatFileCount(info);
1233
+ const count = react_jsx_runtime.jsx("span", {
1234
+ onPointerEnter: () => setHovered(true),
1235
+ style: { cursor: "default" },
1236
+ children: countText
1237
+ });
1238
+ const pending = detail === void 0 || detail === null;
1239
+ return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1240
+ }
1241
+
1242
+ /**
1243
+ * The PR token: the number, the CI state glyph and the GitHub link, and
1244
+ * nothing else. It has NO hover card of its own — the chip/branch card
1245
+ * already lists the branch's commits. ONE element, split so the STATE GLYPH
1246
+ * can take the CI colour while the number keeps the chip's muted colour.
1247
+ */
1248
+ function chipPrTokenHover(info, hoveredLink, setHoveredLink) {
1249
+ const prParts = prTokenParts(info);
1250
+ const prToken = prParts === null ? null : [
1251
+ prParts.number,
1252
+ prParts.glyph === "" ? null : react_jsx_runtime.jsx("span", {
1253
+ key: "pr-state",
1254
+ style: { color: prParts.colour },
1255
+ children: " " + prParts.glyph
1256
+ }),
1257
+ prParts.draft
1258
+ ];
1259
+ const prUrl = prLinkUrl(info);
1260
+ if (prUrl === void 0) {
1261
+ // plain token: no hover, so it wires no pointer handlers
1262
+ return react_jsx_runtime.jsx("span", {
1263
+ // the glyph is not the only channel: the token says what the CI state IS
1264
+ "aria-label": prTokenLabel(info),
1244
1265
  style: { cursor: "default" },
1245
- children: countText
1266
+ children: prToken
1246
1267
  });
1247
- // always wrapped, for the same reason as the branch: the primitive
1248
- // opens on the child's mouseenter, so a wrapper added after the
1249
- // pointer arrives never opens. While the names are in flight the label
1250
- // restates the breakdown the base response already carries.
1251
- const pending = detail === void 0 || detail === null;
1252
- return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1253
- })();
1254
- const prToken = formatPrToken(info);
1255
- const prUrl = prLinkUrl(info);
1256
- // The PR token, with its own hover: the commits IN THIS PR (see
1257
- // PrCommitsList). Built here so the link/plain cases and the tooltip
1258
- // compose once instead of nesting a third conditional in the children.
1259
- // The token is its OWN detail gate: resting on it (or focusing it)
1260
- // enables the same `detail=1` fetch the mark and the branch do. Without
1261
- // that, a pointer going straight to the token had rested on nothing that
1262
- // fetches, so `prCommits` was never requested and this hover could not
1263
- // open at all — the data it renders is detail-only.
1264
- const prTokenHover = (() => {
1265
- const token = prUrl === void 0
1266
- ? react_jsx_runtime.jsx("span", {
1267
- // the glyph is not the only channel: the token says what the
1268
- // CI state IS, for anyone who cannot see it
1269
- "aria-label": prTokenLabel(info),
1270
- onPointerEnter: () => setHovered(true),
1271
- style: { cursor: "default" },
1272
- children: prToken
1273
- })
1274
- : react_jsx_runtime.jsx("a", {
1275
- href: prUrl,
1276
- // a new tab, because navigating THIS one away from the app
1277
- // would lose the conversation; noopener/noreferrer keep the
1278
- // opened tab from getting a handle on it
1279
- target: "_blank",
1280
- rel: "noopener noreferrer",
1281
- "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
1282
- // the chip's own colour rather than the browser's link blue /
1283
- // visited purple, so the token still reads as one row. The
1284
- // affordance is the pointer cursor plus an underline on hover
1285
- // or focus, set HERE rather than left to the host stylesheet:
1286
- // a shell that resets anchors would otherwise drop it silently.
1287
- style: {
1288
- color: "inherit",
1289
- textDecoration: linkHover ? "underline" : "none",
1290
- cursor: "pointer"
1291
- },
1292
- // focus joins hover — the token is keyboard-reachable now, and a
1293
- // keyboard user needs the same "this is a link" signal
1294
- onPointerEnter: () => { setHovered(true); setLinkHover(true); },
1295
- onPointerLeave: () => setLinkHover(false),
1296
- onFocus: () => { setHovered(true); setLinkHover(true); },
1297
- onBlur: () => setLinkHover(false),
1298
- children: prToken
1299
- });
1300
- // always wrapped (see the branch): while the commits are in flight the
1301
- // label names the PR state the base response already carries
1302
- const pending = detail === void 0 || detail === null;
1303
- return withTooltip(token, () => react_jsx_runtime.jsx(PrCommitsList, { info: detail ?? info, pending }), 480);
1304
- })();
1305
- const chip = react_jsx_runtime.jsxs("span", {
1268
+ }
1269
+ return chipAnchor({
1270
+ href: prUrl,
1271
+ ariaLabel: prTokenLabel(info) + ", opens on GitHub in a new tab",
1272
+ underline: hoveredLink === "pr",
1273
+ onPointerEnter: () => setHoveredLink("pr"),
1274
+ onPointerLeave: () => setHoveredLink(null),
1275
+ onFocus: () => setHoveredLink("pr"),
1276
+ onBlur: () => setHoveredLink(null),
1277
+ children: prToken
1278
+ });
1279
+ }
1280
+
1281
+ /** The chip row itself: leading cluster, operation text, count and PR token. */
1282
+ function chipAssemble(leadingHover, text, countHover, prTokenHover) {
1283
+ return react_jsx_runtime.jsxs("span", {
1306
1284
  style: {
1307
1285
  display: "inline-flex",
1308
1286
  alignItems: "center",
1309
1287
  flex: "none",
1310
- // 6px, not 4px: the PR token leads with a space, and CSS drops
1311
- // leading whitespace at the start of a flex item, so 4px read as no
1312
- // separation at all between the branch and `PR#…`
1288
+ // 6px, not 4px: the PR token leads with a space, and CSS drops leading
1289
+ // whitespace at the start of a flex item, so 4px read as no separation
1290
+ // at all between the branch and `PR#…`
1313
1291
  gap: "6px",
1314
1292
  color: "var(--dsw-alias-label-secondary, #5b6570)",
1315
1293
  fontSize: "12px",
@@ -1317,92 +1295,57 @@ window.__ModuleLoader__.load({
1317
1295
  whiteSpace: "nowrap",
1318
1296
  cursor: "default"
1319
1297
  },
1320
- // the long-form explanation, on hover; the inline seam-notice below is
1321
- // the visible channel (its text is the short form, its title this).
1322
- // null until the delayed check in apply() proves the seam never
1323
- // appeared.
1324
- title: seamHint === null ? undefined : seamHint,
1325
1298
  children: [
1326
- // The hover card belongs to the STATUS SYMBOL, not the badge: the
1327
- // circle is the "what does this colour mean" element, and a card
1328
- // opening sideways from a hover on the branch text got in the way of
1329
- // the very pull-down this row now carries. The detail fetch
1330
- // (enabled: hovered) follows the same pointer, so nothing pays for
1331
- // extras the card no longer shows. The seam-absent native title
1332
- // stays on the whole chip — it is about the badge, not the mark.
1333
- hoverableMark,
1334
-
1335
- branchHover,
1336
- react_jsx_runtime.jsx("span", { key: "text", children: text }),
1299
+ leadingHover,
1300
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
1337
1301
  countHover,
1338
- prTokenHover,
1339
- /**
1340
- * The seam-absent notice: the market-install notification for the
1341
- * state a blocked postinstall leaves behind — chip working, sidebar
1342
- * rows structurally off, nothing patched, nothing to restart INTO.
1343
- * It is only ever TRUE (seamHint is set 5s after boot, only after
1344
- * the composed graph is given its chance), and it is self-resolving:
1345
- * apply + restart makes the seam declare, and this never renders
1346
- * again. That is what distinguishes it from the old restart notice,
1347
- * which outlived the restart it asked for. Dismissal is a session
1348
- * value (see seamNoticeDismissed), so the × means "stop asking this
1349
- * boot", never "stop asking forever".
1350
- */
1351
- seamNotice === null
1352
- ? null
1353
- : react_jsx_runtime.jsxs(
1354
- "span",
1355
- {
1356
- style: {
1357
- display: "inline-flex",
1358
- alignItems: "center",
1359
- gap: "6px",
1360
- paddingLeft: "6px",
1361
- borderLeft: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
1362
- whiteSpace: "normal",
1363
- maxWidth: "320px"
1364
- },
1365
- children: [
1366
- react_jsx_runtime.jsx(
1367
- "span",
1368
- {
1369
- title: seamHint,
1370
- children: "sidebar badges off — run `npx dsh-git-badge apply`"
1371
- },
1372
- "explain"
1373
- ),
1374
- react_jsx_runtime.jsx(
1375
- "button",
1376
- {
1377
- "aria-label": "Dismiss the sidebar-badges notice (returns next boot while the seam is absent)",
1378
- onClick: () => {
1379
- seamNoticeDismissed = true;
1380
- bumpNotice((n) => n + 1);
1381
- },
1382
- style: {
1383
- cursor: "pointer",
1384
- color: "inherit",
1385
- background: "none",
1386
- border: "1px solid var(--dsw-alias-border-secondary, #d0d7de)",
1387
- borderRadius: "4px",
1388
- fontSize: "11px",
1389
- lineHeight: "18px",
1390
- padding: "0 6px",
1391
- flex: "none"
1392
- },
1393
- children: "×"
1394
- },
1395
- "dismiss"
1396
- )
1397
- ]
1398
- },
1399
- "seam-notice"
1400
- ),
1302
+ prTokenHover
1401
1303
  ]
1402
1304
  });
1403
- // No Tooltip primitive (a shell that does not seed it): the mark renders
1404
- // bare and the badge still works — the card is an enhancement, the
1405
- // badge is the feature.
1305
+ }
1306
+
1307
+ /**
1308
+ * Chip line in the input row: git state of the workspace the CURRENT
1309
+ * conversation is attached to. It targets the session id and lets the node
1310
+ * half resolve the workspace, so this surface needs no `workspaces`
1311
+ * service, no cwd and no path plumbing at all. `conversation.input.left`
1312
+ * is an upstream additive slot, so it works on every install, and it stays
1313
+ * useful next to the session row because it is context-anchored ("where am
1314
+ * I") rather than surveying.
1315
+ *
1316
+ * This is the ONLY surface that asks the node half for the extras (`pr=1`
1317
+ * always, `detail=1` on hover), because it is the only one that renders
1318
+ * them — see targetQuery.
1319
+ */
1320
+ function ComposerGitChip({ sessionId }) {
1321
+ const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1322
+ // the PR/CI token is always on the chip, so its fetch is not gated
1323
+ const info = useGitStatus(target, { pr: true });
1324
+ // The card's extras cost a `log -10` plus a stash list, so they are fetched
1325
+ // only once a pointer actually RESTS on the chip. A refresh fires on every
1326
+ // file edit, so eagerly asking would add both invocations to every refresh.
1327
+ const [hovered, setHovered] = react.useState(false);
1328
+ // Which of the chip's two links is hovered/focused — ONE enum, not two
1329
+ // booleans: "branch" | "pr" | null. The anchor underlines only when its
1330
+ // own value matches, so hovering one link never underlines the other.
1331
+ const [hoveredLink, setHoveredLink] = react.useState(null);
1332
+ const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
1333
+ if (info === void 0 || info.git !== true) return null;
1334
+
1335
+ const tokenGlyph = chipGlyphNode(info);
1336
+ // The branch name links to the compare view (base...branch). Vouched http(s)
1337
+ // only; new tab, never navigating the conversation away.
1338
+ const isBranchLink = typeof detail?.compareUrl === "string" && detail.compareUrl.startsWith("https://");
1339
+ const branchInner = chipBranchInner(info, detail, isBranchLink, hoveredLink, setHovered, setHoveredLink);
1340
+ // The Tooltip's CHILD must not change element type once the detail payload
1341
+ // lands, so the cluster span is the stable hover surface. ALWAYS wrapped.
1342
+ const leadingHover = chipLeadingHover(info, detail, tokenGlyph, branchInner, isBranchLink, setHovered);
1343
+ const text = formatOperationToken(info);
1344
+ const countHover = chipCountHover(info, detail, setHovered);
1345
+ const prTokenHover = chipPrTokenHover(info, hoveredLink, setHoveredLink);
1346
+ const chip = chipAssemble(leadingHover, text, countHover, prTokenHover);
1347
+ // No Tooltip primitive (a shell that does not seed it): the leading cluster
1348
+ // renders bare and the badge still works — the card is an enhancement.
1406
1349
  return chip;
1407
1350
  }
1408
1351
 
@@ -1412,7 +1355,7 @@ window.__ModuleLoader__.load({
1412
1355
  // node half resolves the workspace, so the client never needs a service
1413
1356
  // lookup. Fewer declared services also means fewer ways to fail to load.
1414
1357
  /** The built-in ranking order — what "Reset to default" restores. */
1415
- const DEFAULT_ORDER = ["operation", "unmerged", "sync", "publish", "merge", "commit", "checks"];
1358
+ const DEFAULT_ORDER = ["operation", "unmerged", "conflict", "review", "sync", "publish", "merge", "commit", "checks"];
1416
1359
 
1417
1360
  /**
1418
1361
  * Move one category within the ranking order — the card's only reorder
@@ -1432,6 +1375,8 @@ window.__ModuleLoader__.load({
1432
1375
  const CATEGORY_LABELS = {
1433
1376
  operation: ["operation", "resume a paused merge/rebase"],
1434
1377
  unmerged: ["unmerged", "resolve conflict markers"],
1378
+ conflict: ["conflict", "a pull request that conflicts with its base"],
1379
+ review: ["review", "a review to address, or a review to wait for"],
1435
1380
  sync: ["sync", "pull, or rebase + confirmed force-push"],
1436
1381
  publish: ["publish", "push local commits"],
1437
1382
  merge: ["merge", "a merge-ready pull request"],
@@ -1567,12 +1512,12 @@ window.__ModuleLoader__.load({
1567
1512
  });
1568
1513
  }
1569
1514
 
1570
- const inject = ["slots", "inputTriggers"];
1515
+ const inject = ["slots"];
1571
1516
 
1572
1517
  /**
1573
- * Register the badge into the seams. The seam owner hands each entry the row
1574
- * owner share as props; the row badge destructures `{ sessionId }` and
1575
- * deliberately ignores the cwd it may also be given.
1518
+ * Register the badge into upstream's own seats. The slot owner hands each
1519
+ * entry the row owner share as props; the row surfaces destructure
1520
+ * `{ sessionId }` and deliberately ignore the cwd they may also be given.
1576
1521
  */
1577
1522
  function apply(ctx) {
1578
1523
  // The /gh command contribution (the (+) commands menu): the state-aware
@@ -1787,33 +1732,17 @@ window.__ModuleLoader__.load({
1787
1732
  }
1788
1733
  }
1789
1734
 
1790
- // inject() re-evaluates when a seam's declaration appears, so boot
1791
- // order relative to the workspace browser does not matter. On an
1792
- // unpatched install the seam is never declared, so this callback never
1793
- // fires and the row badge simply never renders.
1794
- //
1795
- // The registration is reported from INSIDE the callback on purpose: a
1796
- // `spec()` check here at apply() time runs before the workspace browser
1797
- // declares the seam, so it reports the boot race rather than the
1798
- // outcome. That mistake made the old one-shot line claim "sidebar rows
1799
- // = off" while five row badges were rendering.
1800
- let rowsReported = false;
1801
- ctx.slots.inject("sidebar.workspaces.sessionRow", () => {
1802
- if (!rowsReported) {
1803
- rowsReported = true;
1804
- console.info("[dsh-git-badge] surfaces: sidebar session rows = on (seam present).");
1805
- }
1806
- return ctx.slots.register({
1807
- name: "sidebar.workspaces.sessionRow",
1808
- id: "git-badge-row"
1809
- }, SessionGitBadge);
1810
- });
1811
- // The row is ALREADY a HoverCard anchor upstream, so a badge that opened
1812
- // its own tooltip would nest two cards. This renders the provenance line
1735
+ // The row's token rides upstream's OWN `sidebar.session.row.leading`
1736
+ // seat: a 16px cell the workspace browser renders in the row's leading
1737
+ // cluster. No patch and no declaration to make.
1738
+ ctx.slots.inject("sidebar.session.row.leading", () => ctx.slots.register({
1739
+ name: "sidebar.session.row.leading",
1740
+ id: "git-badge-row-leading"
1741
+ }, LeadingTokenGlyph));
1742
+ // The row is ALREADY a HoverCard anchor upstream, so a glyph that opened
1743
+ // its own tooltip would nest two cards. This renders the explanation
1813
1744
  // inside that card instead, through the hover seat upstream declares
1814
- // itself (sidebar.session.row.hover) — no seam patch and no request of
1815
- // its own. Upstream 0.2.0-rc.2 threads renderSlot into the card and adds
1816
- // the seat; before that this needed a patched sessionRow.detail seam.
1745
+ // itself (sidebar.session.row.hover) — no request of its own.
1817
1746
  ctx.slots.inject("sidebar.session.row.hover", () => ctx.slots.register({
1818
1747
  name: "sidebar.session.row.hover",
1819
1748
  id: "git-badge-row-detail"
@@ -1829,29 +1758,9 @@ window.__ModuleLoader__.load({
1829
1758
  id: "git-badge-chip",
1830
1759
  inject: (sessionId) => ({ sessionId })
1831
1760
  }, ComposerGitChip));
1832
- const seamDeclared = ctx.slots.spec("sidebar.workspaces.sessionRow") !== void 0;
1833
- console.info(
1834
- "[dsh-git-badge] surfaces: input chip = on; sidebar session rows = " +
1835
- (seamDeclared
1836
- ? "on (seam present)."
1837
- : // discoverability: the chip works everywhere, but the session rows
1838
- // need the seam patch, and on a market install nothing else says so.
1839
- // The patcher ships IN this package — no clone needed.
1840
- "off (seam absent) — run `npx dsh-git-badge apply` to add them, then restart dsh web.")
1841
- );
1842
- // The chip tooltip can only be set LATE. At apply() time the workspace
1843
- // browser may not have declared the seam yet (the boot race documented
1844
- // above), so claiming absence here would lie to patched installs for the
1845
- // whole session — the exact mistake that made the old one-shot line
1846
- // claim "sidebar rows = off" while five row badges were rendering. Give
1847
- // the composed graph a beat to settle; only a seam that is STILL absent
1848
- // earns the hint, and every chip render after that carries it.
1849
- setTimeout(() => {
1850
- if (rowsReported) return; // seam present; badges rendered — nothing to hint about
1851
- if (ctx.slots.spec("sidebar.workspaces.sessionRow") !== void 0) return;
1852
- seamHint =
1853
- "Session-row badges are off: run `npx dsh-git-badge apply`, then restart dsh web. (Input-chip badges are unaffected.)";
1854
- }, 5000);
1761
+ // No seam, no apply step: the leading seat and the hover seat are both
1762
+ // upstream's own. Report the surfaces once, for diagnostics.
1763
+ console.info("[dsh-git-badge] surfaces: input chip = on; sidebar row token = on (upstream leading seat).");
1855
1764
  // The hover card depends on a primitive the SHELL seeds, not on anything
1856
1765
  // this plugin declares. Report the outcome rather than letting a missing
1857
1766
  // seed look like a missing feature: the try/catch above deliberately keeps
@@ -1870,7 +1779,7 @@ window.__ModuleLoader__.load({
1870
1779
  // Additive; the host reads apply/inject and ignores the rest. The suite
1871
1780
  // drives these to assert the REQUEST contract — which surface asks for the
1872
1781
  // expensive extras — without a browser, a fetch or a network.
1873
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken, ghSkillActions, cleanGate, moveCategory };
1782
+ exports.__internals = { targetQuery, worktreeDetail, linkedWorktreesDetail, ghSkillActions, cleanGate, moveCategory };
1874
1783
  return module.exports;
1875
1784
  }
1876
1785
  });