dsh-git-badge 0.17.0 → 0.17.2

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/CHANGELOG.md CHANGED
@@ -10,6 +10,47 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.17.2] - 2026-09-17
14
+
15
+ ### Fixed
16
+
17
+ - **The default branch's branch hover no longer says `no pull request`.** The
18
+ always-render merge row (0.17.0) only ever made sense on a feature branch:
19
+ `main` is the base pull requests merge INTO, and none is ever opened FROM it,
20
+ so `no pull request` there read as a nag for something that cannot exist. When
21
+ the node half flags the branch as the default one (`defaultBranch: true`, the
22
+ same flag the action rules use to suppress "open a pull request" on `main`),
23
+ the row now reads `base branch · pull requests merge into this`. Its
24
+ base-relative counts are suppressed there too — `N ahead, M behind main` while
25
+ ON `main` is self-referential, and that divergence is already the chip's own
26
+ `↑a ↓b` token. Feature branches are unchanged: still `no pull request`, still
27
+ the counts when they are known.
28
+
29
+ ## [0.17.1] - 2026-09-17
30
+
31
+ ### Fixed
32
+
33
+ - **The branch, file-count and PR hovers open on the FIRST hover.** Those three
34
+ surfaces were only wrapped in the shell's `Tooltip` once the lazy `detail=1`
35
+ payload arrived. That primitive is uncontrolled — it has no `open` prop and
36
+ opens from a `mouseenter` it attaches to its child — so a wrapper mounted after
37
+ the pointer had already arrived could never open for that visit: the first hover
38
+ did nothing, and worked only after leaving and returning (or after hovering the
39
+ status mark had already warmed the cache). All four surfaces are now wrapped
40
+ from the first render, and a label whose detail is still in flight degrades to
41
+ the base response instead of claiming an empty list — the count restates the
42
+ file breakdown, the PR token names the PR. The branch's hover child also stays a
43
+ `span`, so the compare link appearing underneath a resting pointer cannot
44
+ remount it. The status mark's own card was never affected.
45
+
46
+ ### Changed
47
+
48
+ - **Internal simplification pass, no user-facing change** (#52): one
49
+ `singleFlight` now backs the status cache and its in-flight coalescing, one
50
+ patch engine (`seam/patch.js`) backs both `seam/apply.js` and
51
+ `seam/postinstall.js`, and the server returns one `nextActions` shape
52
+ (`{actions, next}`) for the row's action list.
53
+
13
54
  ## [0.17.0] - 2026-09-16
14
55
 
15
56
  ### Added
@@ -278,22 +319,18 @@ npm. 0.6.0 is the first release recorded here.
278
319
 
279
320
  ## [0.14.1] - 2026-09-15
280
321
 
322
+ ### Changed
323
+
281
324
  - **Skill alignment:** the `gh` skill gains an explicit handoff — one verb per
282
325
  invocation is its whole job; the multi-step PR lifecycle (review, merge,
283
326
  verify, cleanup) belongs to the `gh-pr` skill. The `gh-pr` skill states the
284
327
  mirror scope: one-shot verbs are `gh`'s, lifecycle is its own. The catalog
285
328
  stays global-only; repo conventions stay in `AGENTS.md`.
286
-
287
329
  - **The hover card is 33% wider** (360px → 480px) and its `action:` row now
288
330
  carries a trailing what-comment in git-comment convention — e.g.
289
331
  `action: /gh push # push local commits to the remote` — so the row says
290
332
  what the invocation does, not just which invocation it is. The picker in the
291
333
  (+) menu shares the same descriptions.
292
-
293
- ## [0.14.1] - 2026-09-15
294
-
295
- ### Changed
296
-
297
334
  - **The chip's hover card top line is now an `action:` row** — the gh skill
298
335
  invocation the checkout justifies (e.g. `/gh push`), in the card's ordinary
299
336
  label/value layout, replacing the next-step line's why-plus-copy-chip. The
@@ -685,7 +722,22 @@ npm. 0.6.0 is the first release recorded here.
685
722
  - `detail=1` is annotated as having no consumer yet (the hover card remains
686
723
  pending). Its comment previously claimed `log -1` while the code ran `log -3`.
687
724
 
688
- [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.8.0...HEAD
725
+ [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.17.2...HEAD
726
+ [0.17.2]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.17.2
727
+ [0.17.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.17.1
728
+ [0.17.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.17.0
729
+ [0.16.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.16.1
730
+ [0.16.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.16.0
731
+ [0.15.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.15.0
732
+ [0.14.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.14.1
733
+ [0.14.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.14.0
734
+ [0.13.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.13.0
735
+ [0.12.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.12.0
736
+ [0.11.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.11.0
737
+ [0.10.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.10.1
738
+ [0.10.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.10.0
739
+ [0.9.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.9.1
740
+ [0.9.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.9.0
689
741
  [0.8.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.8.0
690
742
  [0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
691
743
  [0.6.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.6.0
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness):
4
4
 
5
5
  - **Input-row chip** — `● main ↑0 ↓2 ✎3` next to the access picker, showing the git state of the workspace the current conversation is attached to. Works on every install.
6
- - **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last three commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
6
+ - **Hover card** — rest a pointer on the chip for what won't fit: the file breakdown (`✎3` split into staged / unstaged / untracked), the last ten commits, and the stash. Fetched only on hover, so the everyday badge pays nothing for it. Works on every install.
7
7
  - **PR / CI token** — `● main ↑0 ↓2 PR#142 ✗` when the branch has a GitHub pull request, read through your own `gh` CLI (the plugin never handles a credential). The token **links to the PR** — it opens in a new tab, and underlines when you hover or focus it. Invisible when `gh` cannot answer: not installed, not logged in, no GitHub remote, offline or rate-limited all render the unchanged chip, and a token with no URL to link to stays plain text.
8
8
  - **Worktree-aware** — a conversation's directory is fixed when it is created, so a conversation that does its work in a linked `git worktree` would otherwise keep reporting the main checkout. The session registers its checkout (`npx dsh-git-badge-checkout <path>`, run by the git-worktree and `/gh checkout` paths), and the badge describes **that worktree** instead — its branch, counts, tree mark and PR/CI token (`🌳 chore/global-skills-tiering ↑0 ↓2 PR#391 ✓` rather than `● main`). The hover card names the tree in a `worktree` row and adds a `checkout` row with the conversation's own directory, so the main checkout's branch and dirty state stay one hover away. Registrations are advisory: git's own worktree list validates the path, entries expire after 24h, and a workspace-targeted row always reports the checkout the registry owns.
9
9
  - **Sidebar session-row action token** — each session row shows the ONE thing that conversation needs from you, or nothing at all: `merge`, `fix CI`, `review`, `resolve`, `pull`, `push`. It is triage rather than status: `merge` appears only when GitHub itself reports `mergeStateStatus: CLEAN`, and every waiting state (draft, blocked, behind, checks still running) stays silent. Hovering the row adds a line naming the checkout and the pull request the action refers to. The token floats to the right of the row, is set heavier than the timestamp beside it, and is coloured by how much it needs you — red for a conflict or a failing build, amber for a requested review, green for `merge`, quiet grey for routine sync. It appears only when the session is **idle** — while a turn is running the row stays quiet, because the checkout is still moving and a suggestion would describe a state that is about to change. Requires the `sidebar.workspaces.sessionRow` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
package/lib/client.js CHANGED
@@ -56,8 +56,17 @@ window.__ModuleLoader__.load({
56
56
  const Menu = primitives === null ? void 0 : primitives.Menu;
57
57
  const rankByName = primitives === null ? void 0 : primitives.rankByName;
58
58
 
59
- /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
59
+ /**
60
+ * Module-level git-status cache. Entries are invalidated by SSE events,
61
+ * never by age — and BOUNDED, because a long-lived tab opens and closes
62
+ * many sessions and nothing else removes an entry. When the cap is
63
+ * exceeded the oldest-INSERTED key is evicted (Map order is insertion,
64
+ * not use); an evicted surface simply refetches, which the in-flight map
65
+ * collapses like any other request.
66
+ */
60
67
  const GIT_CACHE = new Map();
68
+ /** The cache's entry bound — each surface is one key, plus lazy detail keys. */
69
+ const GIT_CACHE_LIMIT = 64;
61
70
  /** In-flight fetches per cacheKey: bursts collapse into one request. */
62
71
  const GIT_INFLIGHT = new Map();
63
72
  /** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
@@ -138,7 +147,7 @@ window.__ModuleLoader__.load({
138
147
  * `options.detail` / `options.pr` ask the node half for the chip's extras
139
148
  * (see targetQuery). `options.enabled: false` holds the fetch back
140
149
  * entirely — that is what makes the hover card's extra fields LAZY: the
141
- * chip renders without them and only starts paying for `log -3` plus a
150
+ * chip renders without them and only starts paying for a `log -10` plus a
142
151
  * stash list once a pointer actually rests on it. The hook is still called
143
152
  * unconditionally (hooks may not be conditional); the GATE is inside.
144
153
  */
@@ -173,6 +182,9 @@ window.__ModuleLoader__.load({
173
182
  if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
174
183
  if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
175
184
  GIT_CACHE.set(cacheKey, { at: Date.now(), data });
185
+ if (GIT_CACHE.size > GIT_CACHE_LIMIT) {
186
+ GIT_CACHE.delete(GIT_CACHE.keys().next().value);
187
+ }
176
188
  if (alive) setState({ key: cacheKey, data });
177
189
  };
178
190
  const load = () => {
@@ -240,13 +252,10 @@ window.__ModuleLoader__.load({
240
252
  * `/gh pr` was offered on the default branch. The node half owns the
241
253
  * rules and the ranking now; the client adds presentation only — the risk
242
254
  * gate for `clean`, and the labels.
243
- *
244
- * Falls back to the single `next` for a payload from an older node half.
245
255
  */
246
256
  function ghSkillActions(info) {
247
257
  if (info === void 0 || info === null || info.git !== true) return [];
248
- if (Array.isArray(info.actions)) return info.actions;
249
- return info.next === void 0 || info.next === null ? [] : [info.next];
258
+ return Array.isArray(info.actions) ? info.actions : [];
250
259
  }
251
260
 
252
261
  /**
@@ -747,9 +756,15 @@ window.__ModuleLoader__.load({
747
756
  * columns line up in both by construction), and the card's own width
748
757
  * behaviour: full-width lines, nowrap, ellipsis.
749
758
  */
750
- function PrCommitsList({ info }) {
759
+ function PrCommitsList({ info, pending = false }) {
751
760
  const commits = info?.prCommits;
752
761
  if (!Array.isArray(commits) || commits.length === 0) {
762
+ // `pending`: the detail payload has not landed yet, so "no commits"
763
+ // would be a false negative — name the pull request the token points
764
+ // at instead, the way the mark's card degrades to the base response.
765
+ if (pending) {
766
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatPrDetail(info?.pr) ?? "loading\u2026" });
767
+ }
753
768
  return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no commits beyond the base branch" });
754
769
  }
755
770
  return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children: [
@@ -783,12 +798,13 @@ window.__ModuleLoader__.load({
783
798
  * the one question the count poses ("what are these?"). One stacked
784
799
  * column with sub-headers: UNSTAGED (tracked, edited — valid regardless
785
800
  * of the collapsed untracked retry) then UNTRACKED. Reads the same lazy
786
- * detail payload the mark's card uses; no tooltip at all when neither
787
- * list has names (the count itself is absent on a clean tree, so an
788
- * empty list is rare — a mid-fetch hover shows it plain until the
789
- * response lands).
801
+ * detail payload the mark's card uses. The wrapper is always mounted (see
802
+ * withTooltip) and the label degrades while the names are in flight: the
803
+ * COUNTS are already in the base response, so it restates the breakdown
804
+ * rather than claiming an empty list. "no changed files" is reserved for a
805
+ * landed payload that genuinely has no names.
790
806
  */
791
- function CountNames({ info }) {
807
+ function CountNames({ info, pending = false }) {
792
808
  const sections = [
793
809
  {
794
810
  label: "staged",
@@ -807,6 +823,11 @@ window.__ModuleLoader__.load({
807
823
  }
808
824
  ].filter((section) => Array.isArray(section.names) && section.names.length > 0);
809
825
  if (sections.length === 0) {
826
+ // `pending`: the names have not been fetched yet, but the COUNTS have —
827
+ // restate those rather than the false "no changed files".
828
+ if (pending) {
829
+ return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: formatFileBreakdown(info) });
830
+ }
810
831
  return react_jsx_runtime.jsx("span", { style: CARD_CONTAINER, children: "no changed files" });
811
832
  }
812
833
  return react_jsx_runtime.jsxs("div", { style: CARD_CONTAINER, children:
@@ -871,10 +892,22 @@ window.__ModuleLoader__.load({
871
892
  } else if (pr.state === "failing") mergeParts.push("blocked: checks failing");
872
893
  else if (pr.mergeState === "CLEAN" || (pr.state === "passing" && pr.review === "APPROVED")) mergeParts.push("ready to merge");
873
894
  else if (pr.review === "REVIEW_REQUIRED") mergeParts.push("blocked: review required");
895
+ } else if (info?.defaultBranch === true) {
896
+ // The default branch is the BASE, not a branch with a missing PR:
897
+ // pull requests merge INTO it and none is ever opened FROM it, so
898
+ // "no pull request" here reads as a nag for something that cannot
899
+ // exist. The node half computes `defaultBranch` for exactly this
900
+ // suppression (see the comment where it is set in lib/index.js);
901
+ // until now only the action rules consulted it, not this row.
902
+ mergeParts.push("base branch \u00B7 pull requests merge into this");
874
903
  } else {
875
904
  mergeParts.push("no pull request");
876
905
  }
877
- if (info?.mainAhead !== void 0 || info?.mainBehind !== void 0) {
906
+ // The counts are this branch's standing against its BASE, so on the
907
+ // base itself they are self-referential ("N ahead, M behind main" while
908
+ // ON main) and the divergence they would state is already the chip's own
909
+ // \u2191a \u2193b token. Suppress them on the default branch, with or without a PR.
910
+ if (info?.defaultBranch !== true && (info?.mainAhead !== void 0 || info?.mainBehind !== void 0)) {
878
911
  mergeParts.push((info.mainAhead || 0) + " ahead, " + (info.mainBehind || 0) + " behind main");
879
912
  }
880
913
  add("merge", mergeParts.join(" \u00B7 "));
@@ -1009,6 +1042,24 @@ window.__ModuleLoader__.load({
1009
1042
  }
1010
1043
 
1011
1044
  //#region composer chip (upstream additive surface: conversation.input.left)
1045
+ /**
1046
+ * The ONE copy of the hover-card degrade rule: wrap a node in the shell's
1047
+ * Tooltip when the primitive exists, and render the node bare otherwise — a
1048
+ * missing card is a smaller failure than a missing badge. `label` is the
1049
+ * thunk the Tooltip calls only when it opens, so the card's element tree
1050
+ * stays out of closed tooltips.
1051
+ *
1052
+ * ALWAYS mount the wrapper — never conditionally, once its data arrives.
1053
+ * The primitive is uncontrolled (it has no `open` prop) and opens on the
1054
+ * CHILD's mouseenter, so a wrapper mounted after the pointer is already
1055
+ * inside can never open for that visit: the pointer has to leave and
1056
+ * return. Gate the LABEL's content instead.
1057
+ */
1058
+ function withTooltip(node, label, maxWidth) {
1059
+ if (Tooltip === void 0) return node;
1060
+ return react_jsx_runtime.jsx(Tooltip, { side: "top", maxWidth, label, children: node });
1061
+ }
1062
+
1012
1063
  /**
1013
1064
  * Chip line in the input row: git state of the workspace the CURRENT
1014
1065
  * conversation is attached to. It targets the session id and lets the node
@@ -1044,7 +1095,7 @@ window.__ModuleLoader__.load({
1044
1095
  const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1045
1096
  // the PR/CI token is always on the chip, so its fetch is not gated
1046
1097
  const info = useGitStatus(target, { pr: true });
1047
- // The card's extras cost a `log -3` plus a stash list, so they are
1098
+ // The card's extras cost a `log -10` plus a stash list, so they are
1048
1099
  // fetched only once a pointer actually RESTS on the chip, then kept
1049
1100
  // fresh by the same SSE path. Eagerly asking would add both invocations
1050
1101
  // to every refresh — and a refresh fires on every file edit.
@@ -1069,31 +1120,25 @@ window.__ModuleLoader__.load({
1069
1120
  // so the extras are paid for exactly when the card that can render them
1070
1121
  // is about to open. Defined inside the component: it closes over the
1071
1122
  // hovered state that gates the detail fetch.
1072
- const hoverableMark = (() => {
1073
- const mark = react_jsx_runtime.jsx("span", {
1123
+ const hoverableMark = withTooltip(
1124
+ react_jsx_runtime.jsx("span", {
1074
1125
  onPointerEnter: () => setHovered(true),
1075
1126
  style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
1076
1127
  children: react_jsx_runtime.jsx(StatusMark, { info })
1077
- });
1078
- if (Tooltip === void 0) return mark;
1079
- return react_jsx_runtime.jsx(Tooltip, {
1080
- side: "top",
1081
- // matches the body cap: the tooltip must never be the clamp
1082
- maxWidth: 600,
1083
- // a function label keeps the card's element tree out of every render
1084
- // until the tooltip actually opens
1085
- label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1086
- children: mark
1087
- });
1088
- })();
1128
+ }),
1129
+ // a function label keeps the card's element tree out of every render
1130
+ // until the tooltip actually opens
1131
+ () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1132
+ 600 // matches the body cap: the tooltip must never be the clamp
1133
+ );
1089
1134
  // the mark is an element now rather than a leading glyph in the string, so
1090
1135
  // the SAME StatusMark the sidebar row draws carries the status here too;
1091
1136
  // the container's 4px gap supplies the space the emoji's own did
1092
1137
  // The branch NAME is its own hover surface: the commits this branch adds
1093
1138
  // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1094
1139
  // the leading text (operation token) and the count keep their own
1095
- // surfaces. Degrades to plain text without the Tooltip primitive or
1096
- // when the branch adds nothing beyond upstream.
1140
+ // surfaces. The wrapper is always mounted (see withTooltip); without the
1141
+ // Tooltip primitive the name renders plain.
1097
1142
  const branchHover = (() => {
1098
1143
  // The branch name links to the compare view (base...branch) — the web
1099
1144
  // page of exactly what the lineage tooltip lists. Vouched http(s)
@@ -1106,14 +1151,21 @@ window.__ModuleLoader__.load({
1106
1151
  // count stays its own surface — it answers a different question.
1107
1152
  const sync = formatSync(info);
1108
1153
  const label = info.branch + sync;
1109
- const branch = isLink
1154
+ // The Tooltip's CHILD must not change element type once the detail
1155
+ // payload lands: the primitive opens on the child's mouseenter and has
1156
+ // no `open` prop, so a node replaced under the pointer may never be
1157
+ // re-entered. The outer span is the stable hover surface; only this
1158
+ // inner node swaps (plain text -> anchor) as the compare URL arrives.
1159
+ const inner = isLink
1110
1160
  ? react_jsx_runtime.jsx("a", {
1111
1161
  href: detail.compareUrl,
1112
1162
  target: "_blank",
1113
1163
  rel: "noopener noreferrer",
1114
1164
  "aria-label": "Compare " + info.branch + " with main on GitHub" + (sync === "" ? "" : ", " + (info.ahead || 0) + " ahead and " + (info.behind || 0) + " behind upstream"),
1115
- onPointerEnter: () => { setHovered(true); setBranchLinkHover(true); },
1165
+ onPointerEnter: () => setBranchLinkHover(true),
1116
1166
  onPointerLeave: () => setBranchLinkHover(false),
1167
+ // focus joins hover: a keyboard user needs the same underline, and
1168
+ // focus (unlike a pointer) must also start the detail fetch
1117
1169
  onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1118
1170
  onBlur: () => setBranchLinkHover(false),
1119
1171
  style: {
@@ -1123,22 +1175,17 @@ window.__ModuleLoader__.load({
1123
1175
  },
1124
1176
  children: label
1125
1177
  })
1126
- : react_jsx_runtime.jsx("span", {
1127
- onPointerEnter: () => setHovered(true),
1128
- style: { cursor: "default" },
1129
- children: label
1130
- });
1131
- if (Tooltip === void 0 || detail === void 0 || detail === null) {
1132
- // mid-fetch the branch shows plain; the lineage tooltip is
1133
- // unconditional once the payload lands
1134
- return branch;
1135
- }
1136
- return react_jsx_runtime.jsx(Tooltip, {
1137
- side: "top",
1138
- maxWidth: 480,
1139
- label: () => react_jsx_runtime.jsx(BranchLineage, { info: detail }),
1140
- children: branch
1178
+ : label;
1179
+ const branch = react_jsx_runtime.jsx("span", {
1180
+ onPointerEnter: () => setHovered(true),
1181
+ style: { cursor: isLink ? "pointer" : "default" },
1182
+ children: inner
1141
1183
  });
1184
+ // ALWAYS wrapped. A wrapper mounted only once the lazy detail arrives
1185
+ // cannot open for the visit that fetched it (see the child-stability
1186
+ // note above); until the payload lands the lineage degrades to the base
1187
+ // response, the way the mark's own card already does.
1188
+ return withTooltip(branch, () => react_jsx_runtime.jsx(BranchLineage, { info: detail ?? info }), 480);
1142
1189
  })();
1143
1190
  const text = formatOperationToken(info);
1144
1191
  // The ✎n count is its OWN hover surface: a names-only tooltip (see
@@ -1153,17 +1200,12 @@ window.__ModuleLoader__.load({
1153
1200
  style: { cursor: "default" },
1154
1201
  children: countText
1155
1202
  });
1156
- const hasNames = (Array.isArray(detail?.untrackedNames) && detail.untrackedNames.length > 0)
1157
- || (Array.isArray(detail?.unstagedNames) && detail.unstagedNames.length > 0);
1158
- if (Tooltip === void 0 || !hasNames) {
1159
- return count;
1160
- }
1161
- return react_jsx_runtime.jsx(Tooltip, {
1162
- side: "top",
1163
- maxWidth: 480,
1164
- label: () => react_jsx_runtime.jsx(CountNames, { info: detail }),
1165
- children: count
1166
- });
1203
+ // always wrapped, for the same reason as the branch: the primitive
1204
+ // opens on the child's mouseenter, so a wrapper added after the
1205
+ // pointer arrives never opens. While the names are in flight the label
1206
+ // restates the breakdown the base response already carries.
1207
+ const pending = detail === void 0 || detail === null;
1208
+ return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1167
1209
  })();
1168
1210
  const prToken = formatPrToken(info);
1169
1211
  const prUrl = prLinkUrl(info);
@@ -1211,16 +1253,10 @@ window.__ModuleLoader__.load({
1211
1253
  onBlur: () => setLinkHover(false),
1212
1254
  children: prToken
1213
1255
  });
1214
- if (Tooltip === void 0 || detail === void 0 || detail === null
1215
- || !Array.isArray(detail.prCommits) || detail.prCommits.length === 0) {
1216
- return token;
1217
- }
1218
- return react_jsx_runtime.jsx(Tooltip, {
1219
- side: "top",
1220
- maxWidth: 480,
1221
- label: () => react_jsx_runtime.jsx(PrCommitsList, { info: detail }),
1222
- children: token
1223
- });
1256
+ // always wrapped (see the branch): while the commits are in flight the
1257
+ // label names the PR state the base response already carries
1258
+ const pending = detail === void 0 || detail === null;
1259
+ return withTooltip(token, () => react_jsx_runtime.jsx(PrCommitsList, { info: detail ?? info, pending }), 480);
1224
1260
  })();
1225
1261
  const chip = react_jsx_runtime.jsxs("span", {
1226
1262
  style: {