dsh-git-badge 0.17.0 → 0.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -10,6 +10,31 @@ npm. 0.6.0 is the first release recorded here.
10
10
 
11
11
  ## [Unreleased]
12
12
 
13
+ ## [0.17.1] - 2026-09-17
14
+
15
+ ### Fixed
16
+
17
+ - **The branch, file-count and PR hovers open on the FIRST hover.** Those three
18
+ surfaces were only wrapped in the shell's `Tooltip` once the lazy `detail=1`
19
+ payload arrived. That primitive is uncontrolled — it has no `open` prop and
20
+ opens from a `mouseenter` it attaches to its child — so a wrapper mounted after
21
+ the pointer had already arrived could never open for that visit: the first hover
22
+ did nothing, and worked only after leaving and returning (or after hovering the
23
+ status mark had already warmed the cache). All four surfaces are now wrapped
24
+ from the first render, and a label whose detail is still in flight degrades to
25
+ the base response instead of claiming an empty list — the count restates the
26
+ file breakdown, the PR token names the PR. The branch's hover child also stays a
27
+ `span`, so the compare link appearing underneath a resting pointer cannot
28
+ remount it. The status mark's own card was never affected.
29
+
30
+ ### Changed
31
+
32
+ - **Internal simplification pass, no user-facing change** (#52): one
33
+ `singleFlight` now backs the status cache and its in-flight coalescing, one
34
+ patch engine (`seam/patch.js`) backs both `seam/apply.js` and
35
+ `seam/postinstall.js`, and the server returns one `nextActions` shape
36
+ (`{actions, next}`) for the row's action list.
37
+
13
38
  ## [0.17.0] - 2026-09-16
14
39
 
15
40
  ### Added
@@ -278,22 +303,18 @@ npm. 0.6.0 is the first release recorded here.
278
303
 
279
304
  ## [0.14.1] - 2026-09-15
280
305
 
306
+ ### Changed
307
+
281
308
  - **Skill alignment:** the `gh` skill gains an explicit handoff — one verb per
282
309
  invocation is its whole job; the multi-step PR lifecycle (review, merge,
283
310
  verify, cleanup) belongs to the `gh-pr` skill. The `gh-pr` skill states the
284
311
  mirror scope: one-shot verbs are `gh`'s, lifecycle is its own. The catalog
285
312
  stays global-only; repo conventions stay in `AGENTS.md`.
286
-
287
313
  - **The hover card is 33% wider** (360px → 480px) and its `action:` row now
288
314
  carries a trailing what-comment in git-comment convention — e.g.
289
315
  `action: /gh push # push local commits to the remote` — so the row says
290
316
  what the invocation does, not just which invocation it is. The picker in the
291
317
  (+) menu shares the same descriptions.
292
-
293
- ## [0.14.1] - 2026-09-15
294
-
295
- ### Changed
296
-
297
318
  - **The chip's hover card top line is now an `action:` row** — the gh skill
298
319
  invocation the checkout justifies (e.g. `/gh push`), in the card's ordinary
299
320
  label/value layout, replacing the next-step line's why-plus-copy-chip. The
@@ -685,7 +706,20 @@ npm. 0.6.0 is the first release recorded here.
685
706
  - `detail=1` is annotated as having no consumer yet (the hover card remains
686
707
  pending). Its comment previously claimed `log -1` while the code ran `log -3`.
687
708
 
688
- [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.8.0...HEAD
709
+ [Unreleased]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/compare/v0.17.0...HEAD
710
+ [0.17.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.17.0
711
+ [0.16.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.16.1
712
+ [0.16.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.16.0
713
+ [0.15.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.15.0
714
+ [0.14.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.14.1
715
+ [0.14.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.14.0
716
+ [0.13.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.13.0
717
+ [0.12.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.12.0
718
+ [0.11.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.11.0
719
+ [0.10.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.10.1
720
+ [0.10.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.10.0
721
+ [0.9.1]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.9.1
722
+ [0.9.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.9.0
689
723
  [0.8.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.8.0
690
724
  [0.7.0]: https://github.com/Kevin-McIsaac/dsh-workspace-git-badge/releases/tag/v0.7.0
691
725
  [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:
@@ -1009,6 +1030,24 @@ window.__ModuleLoader__.load({
1009
1030
  }
1010
1031
 
1011
1032
  //#region composer chip (upstream additive surface: conversation.input.left)
1033
+ /**
1034
+ * The ONE copy of the hover-card degrade rule: wrap a node in the shell's
1035
+ * Tooltip when the primitive exists, and render the node bare otherwise — a
1036
+ * missing card is a smaller failure than a missing badge. `label` is the
1037
+ * thunk the Tooltip calls only when it opens, so the card's element tree
1038
+ * stays out of closed tooltips.
1039
+ *
1040
+ * ALWAYS mount the wrapper — never conditionally, once its data arrives.
1041
+ * The primitive is uncontrolled (it has no `open` prop) and opens on the
1042
+ * CHILD's mouseenter, so a wrapper mounted after the pointer is already
1043
+ * inside can never open for that visit: the pointer has to leave and
1044
+ * return. Gate the LABEL's content instead.
1045
+ */
1046
+ function withTooltip(node, label, maxWidth) {
1047
+ if (Tooltip === void 0) return node;
1048
+ return react_jsx_runtime.jsx(Tooltip, { side: "top", maxWidth, label, children: node });
1049
+ }
1050
+
1012
1051
  /**
1013
1052
  * Chip line in the input row: git state of the workspace the CURRENT
1014
1053
  * conversation is attached to. It targets the session id and lets the node
@@ -1044,7 +1083,7 @@ window.__ModuleLoader__.load({
1044
1083
  const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
1045
1084
  // the PR/CI token is always on the chip, so its fetch is not gated
1046
1085
  const info = useGitStatus(target, { pr: true });
1047
- // The card's extras cost a `log -3` plus a stash list, so they are
1086
+ // The card's extras cost a `log -10` plus a stash list, so they are
1048
1087
  // fetched only once a pointer actually RESTS on the chip, then kept
1049
1088
  // fresh by the same SSE path. Eagerly asking would add both invocations
1050
1089
  // to every refresh — and a refresh fires on every file edit.
@@ -1069,31 +1108,25 @@ window.__ModuleLoader__.load({
1069
1108
  // so the extras are paid for exactly when the card that can render them
1070
1109
  // is about to open. Defined inside the component: it closes over the
1071
1110
  // hovered state that gates the detail fetch.
1072
- const hoverableMark = (() => {
1073
- const mark = react_jsx_runtime.jsx("span", {
1111
+ const hoverableMark = withTooltip(
1112
+ react_jsx_runtime.jsx("span", {
1074
1113
  onPointerEnter: () => setHovered(true),
1075
1114
  style: { display: "inline-flex", alignItems: "center", flex: "none", cursor: "default" },
1076
1115
  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
- })();
1116
+ }),
1117
+ // a function label keeps the card's element tree out of every render
1118
+ // until the tooltip actually opens
1119
+ () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
1120
+ 600 // matches the body cap: the tooltip must never be the clamp
1121
+ );
1089
1122
  // the mark is an element now rather than a leading glyph in the string, so
1090
1123
  // the SAME StatusMark the sidebar row draws carries the status here too;
1091
1124
  // the container's 4px gap supplies the space the emoji's own did
1092
1125
  // The branch NAME is its own hover surface: the commits this branch adds
1093
1126
  // (BranchCommitsList), sharing the mark's lazy detail fetch. The rest of
1094
1127
  // 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.
1128
+ // surfaces. The wrapper is always mounted (see withTooltip); without the
1129
+ // Tooltip primitive the name renders plain.
1097
1130
  const branchHover = (() => {
1098
1131
  // The branch name links to the compare view (base...branch) — the web
1099
1132
  // page of exactly what the lineage tooltip lists. Vouched http(s)
@@ -1106,14 +1139,21 @@ window.__ModuleLoader__.load({
1106
1139
  // count stays its own surface — it answers a different question.
1107
1140
  const sync = formatSync(info);
1108
1141
  const label = info.branch + sync;
1109
- const branch = isLink
1142
+ // The Tooltip's CHILD must not change element type once the detail
1143
+ // payload lands: the primitive opens on the child's mouseenter and has
1144
+ // no `open` prop, so a node replaced under the pointer may never be
1145
+ // re-entered. The outer span is the stable hover surface; only this
1146
+ // inner node swaps (plain text -> anchor) as the compare URL arrives.
1147
+ const inner = isLink
1110
1148
  ? react_jsx_runtime.jsx("a", {
1111
1149
  href: detail.compareUrl,
1112
1150
  target: "_blank",
1113
1151
  rel: "noopener noreferrer",
1114
1152
  "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); },
1153
+ onPointerEnter: () => setBranchLinkHover(true),
1116
1154
  onPointerLeave: () => setBranchLinkHover(false),
1155
+ // focus joins hover: a keyboard user needs the same underline, and
1156
+ // focus (unlike a pointer) must also start the detail fetch
1117
1157
  onFocus: () => { setHovered(true); setBranchLinkHover(true); },
1118
1158
  onBlur: () => setBranchLinkHover(false),
1119
1159
  style: {
@@ -1123,22 +1163,17 @@ window.__ModuleLoader__.load({
1123
1163
  },
1124
1164
  children: label
1125
1165
  })
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
1166
+ : label;
1167
+ const branch = react_jsx_runtime.jsx("span", {
1168
+ onPointerEnter: () => setHovered(true),
1169
+ style: { cursor: isLink ? "pointer" : "default" },
1170
+ children: inner
1141
1171
  });
1172
+ // ALWAYS wrapped. A wrapper mounted only once the lazy detail arrives
1173
+ // cannot open for the visit that fetched it (see the child-stability
1174
+ // note above); until the payload lands the lineage degrades to the base
1175
+ // response, the way the mark's own card already does.
1176
+ return withTooltip(branch, () => react_jsx_runtime.jsx(BranchLineage, { info: detail ?? info }), 480);
1142
1177
  })();
1143
1178
  const text = formatOperationToken(info);
1144
1179
  // The ✎n count is its OWN hover surface: a names-only tooltip (see
@@ -1153,17 +1188,12 @@ window.__ModuleLoader__.load({
1153
1188
  style: { cursor: "default" },
1154
1189
  children: countText
1155
1190
  });
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
- });
1191
+ // always wrapped, for the same reason as the branch: the primitive
1192
+ // opens on the child's mouseenter, so a wrapper added after the
1193
+ // pointer arrives never opens. While the names are in flight the label
1194
+ // restates the breakdown the base response already carries.
1195
+ const pending = detail === void 0 || detail === null;
1196
+ return withTooltip(count, () => react_jsx_runtime.jsx(CountNames, { info: detail ?? info, pending }), 480);
1167
1197
  })();
1168
1198
  const prToken = formatPrToken(info);
1169
1199
  const prUrl = prLinkUrl(info);
@@ -1211,16 +1241,10 @@ window.__ModuleLoader__.load({
1211
1241
  onBlur: () => setLinkHover(false),
1212
1242
  children: prToken
1213
1243
  });
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
- });
1244
+ // always wrapped (see the branch): while the commits are in flight the
1245
+ // label names the PR state the base response already carries
1246
+ const pending = detail === void 0 || detail === null;
1247
+ return withTooltip(token, () => react_jsx_runtime.jsx(PrCommitsList, { info: detail ?? info, pending }), 480);
1224
1248
  })();
1225
1249
  const chip = react_jsx_runtime.jsxs("span", {
1226
1250
  style: {