dsh-git-badge 0.5.5 → 0.7.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,9 +1,16 @@
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.
7
+ *
4
8
  * Surfaces:
5
- * - sidebar.workspaces.row (seam): full row badge — emoji, branch, sync, ✎files
6
- * - conversation.input.left (upstream): compact chip — emoji + branch only
9
+ * - sidebar.workspaces.row (seam): row badge — workspace name, the status mark
10
+ * floated right, and a worktree's name beside it. Deliberately shows NO
11
+ * branch: the chip is the surface that names it.
12
+ * - conversation.input.left (upstream): chip — status mark + branch + a
13
+ * worktree's name + in-progress operation token + sync/dirty suffix
7
14
  * The hover card is intentionally untouched.
8
15
  */
9
16
  window.__ModuleLoader__.load({
@@ -22,6 +29,19 @@ window.__ModuleLoader__.load({
22
29
  /** Slow safety-net poll: refreshes even if the SSE stream is silently dead. */
23
30
  const FALLBACK_POLL_MS = 60000;
24
31
 
32
+ /**
33
+ * A request target names WHO is asking, never a filesystem path — the node
34
+ * half resolves the directory itself, so a surface never needs to know
35
+ * one. The chip targets its conversation (`session`); a sidebar row
36
+ * targets the workspace id the seam hands it. The query string doubles as
37
+ * the cache key.
38
+ */
39
+ function targetQuery(target) {
40
+ if (target === void 0 || target.id === void 0) return void 0;
41
+ const param = target.kind === "workspace" ? "workspace" : "session";
42
+ return param + "=" + encodeURIComponent(target.id);
43
+ }
44
+
25
45
  //#region SSE change feed (one EventSource per page, ref-counted)
26
46
  const eventListeners = new Set();
27
47
  let eventSource = null;
@@ -32,25 +52,27 @@ window.__ModuleLoader__.load({
32
52
  eventRefs += 1;
33
53
  if (eventSource === null) {
34
54
  eventSource = new EventSource("/api/git-badge/events");
35
- eventSource.onmessage = (message) => {
36
- let path;
55
+ // NAMED frames: the node half writes `event: change`, and onmessage
56
+ // never fires for a named event
57
+ eventSource.addEventListener("change", (message) => {
58
+ let payload;
37
59
  try {
38
- path = JSON.parse(message.data).path;
60
+ payload = JSON.parse(message.data);
39
61
  } catch {
40
62
  return;
41
63
  }
42
- // invalidate every cache entry for this workspace, then refetch
43
- for (const key of [...GIT_CACHE.keys()]) {
44
- if (key === path) GIT_CACHE.delete(key);
45
- }
64
+ // The server names the workspace it watched, so a subscriber that
65
+ // asked by id matches without ever knowing a path. Each
66
+ // subscriber refetches itself, and the per-key in-flight map
67
+ // collapses the resulting burst into one request.
46
68
  for (const fn of [...eventListeners]) {
47
69
  try {
48
- fn(path);
70
+ fn(payload);
49
71
  } catch {
50
72
  /* one bad subscriber must not starve the rest */
51
73
  }
52
74
  }
53
- };
75
+ });
54
76
  }
55
77
  return () => {
56
78
  eventListeners.delete(onChange);
@@ -65,76 +87,122 @@ window.__ModuleLoader__.load({
65
87
 
66
88
  /**
67
89
  * Shared git-status hook. Fetches once on mount and then only when the
68
- * node half's watcher reports a change for this workspace (SSE), plus a
69
- * slow safety-net poll in case the stream dies silently. Returns
70
- * undefined while loading and for non-git paths.
90
+ * node half's watcher reports a change for this target (SSE), plus a slow
91
+ * safety-net poll in case the stream dies silently. Returns undefined
92
+ * while loading and for non-git workspaces.
71
93
  */
72
- function useGitStatus(cwd) {
73
- const cacheKey = cwd;
94
+ function useGitStatus(target) {
95
+ const cacheKey = targetQuery(target);
74
96
  const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
75
- const [info, setInfo] = react.useState(hit !== void 0 ? hit.data : void 0);
97
+ // state carries the key it belongs to: switching conversations changes
98
+ // the key, and showing the previous session's badge until the new fetch
99
+ // lands would be wrong
100
+ const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
76
101
  react.useEffect(() => {
77
- if (cacheKey === void 0) return;
78
- let alive = true;
79
- const apply = (data) => {
80
- // A degraded response (git timeout/failure) must never clobber a
81
- // good cached badge — keep the last-known state until a real
82
- // event or the fallback poll succeeds.
83
- if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
84
- GIT_CACHE.set(cacheKey, { at: Date.now(), data });
85
- if (alive) setInfo(data);
86
- };
87
- const load = () => {
88
- // dedupe: an in-flight fetch for this key serves all callers
89
- let pending = GIT_INFLIGHT.get(cacheKey);
90
- if (pending === void 0) {
91
- pending = fetch("/api/git-badge?path=" + encodeURIComponent(cwd))
92
- .then((r) => r.json())
93
- .finally(() => GIT_INFLIGHT.delete(cacheKey));
94
- GIT_INFLIGHT.set(cacheKey, pending);
102
+ if (cacheKey === void 0) return;
103
+ let alive = true;
104
+ // The workspace this target resolved to, taken from the response (the
105
+ // server echoes it). THIS is what events are matched against: an event
106
+ // carries a workspace id, and a session-targeted chip has only a
107
+ // session id, so comparing the two directly never matches.
108
+ let resolvedWorkspace;
109
+ // Re-seed from cache when the key changes (switching conversations):
110
+ // render the last-known badge immediately instead of blanking until the
111
+ // fetch lands, and take the workspace id from it so events still match.
112
+ const cached = GIT_CACHE.get(cacheKey);
113
+ if (cached !== void 0) {
114
+ if (typeof cached.data?.workspace === "string") resolvedWorkspace = cached.data.workspace;
115
+ setState({ key: cacheKey, data: cached.data });
95
116
  }
96
- pending.then(apply).catch(() => {});
97
- };
98
- load();
99
- // this workspace's watcher events → refetch
100
- const unsubscribe = subscribeGitEvents((path) => {
101
- if (path === cwd) load();
102
- });
103
- const fallback = setInterval(load, FALLBACK_POLL_MS);
104
- return () => {
105
- alive = false;
106
- unsubscribe();
107
- clearInterval(fallback);
108
- };
109
- }, [cacheKey]);
110
- return info;
117
+ const apply = (data) => {
118
+ // A degraded response (git timeout/failure) must never clobber a
119
+ // good cached badge — keep the last-known state until a real
120
+ // event or the fallback poll succeeds.
121
+ if (data !== null && data.error !== void 0 && GIT_CACHE.has(cacheKey)) return;
122
+ if (data !== null && typeof data.workspace === "string") resolvedWorkspace = data.workspace;
123
+ GIT_CACHE.set(cacheKey, { at: Date.now(), data });
124
+ if (alive) setState({ key: cacheKey, data });
125
+ };
126
+ const load = () => {
127
+ // dedupe: an in-flight fetch for this key serves all callers
128
+ let pending = GIT_INFLIGHT.get(cacheKey);
129
+ if (pending === void 0) {
130
+ pending = fetch("/api/git-badge?" + cacheKey)
131
+ .then((r) => r.json())
132
+ .finally(() => GIT_INFLIGHT.delete(cacheKey));
133
+ GIT_INFLIGHT.set(cacheKey, pending);
134
+ }
135
+ pending.then(apply).catch(() => {});
136
+ };
137
+ load();
138
+ const unsubscribe = subscribeGitEvents((payload) => {
139
+ // An event with no workspace id (no watcher record for that path)
140
+ // cannot be attributed, and neither can one arriving before we have
141
+ // resolved our own target — refetch rather than guess, since a
142
+ // missed refresh is the bug this guards and an extra request is cheap
143
+ // (the in-flight map collapses bursts).
144
+ if (payload.workspace === void 0 || resolvedWorkspace === void 0 || payload.workspace === resolvedWorkspace) load();
145
+ });
146
+ const fallback = setInterval(load, FALLBACK_POLL_MS);
147
+ return () => {
148
+ alive = false;
149
+ unsubscribe();
150
+ clearInterval(fallback);
151
+ };
152
+ }, [cacheKey]);
153
+ return state.key === cacheKey ? state.data : void 0;
111
154
  }
112
155
 
113
156
  /**
114
- * Three-state dot color, shared by the sidebar row badge and the
115
- * composer chip. Checked top-down, first match wins:
116
- * - RED: unmerged files (mid-conflict, git is blocked) — or a dirty
157
+ * Three-state status, checked top-down, first match wins:
158
+ * - "error": unmerged files (mid-conflict, git is blocked) — or a dirty
117
159
  * tree that is ALSO behind upstream (unsaved edits on an outdated
118
160
  * base: commit-then-pull friction ahead).
119
- * - YELLOW: dirty files, or any ahead/behind (routine work or sync
161
+ * - "warn": dirty files, or any ahead/behind (routine work or sync
120
162
  * pending — including the previously "quiet" green-but-↓1 case).
121
- * - GREEN: clean and in sync.
163
+ * - "ok": clean and in sync.
122
164
  * Red never means merely "behind": a clean tree one commit behind is
123
165
  * normal between pulls, not an alarm.
166
+ *
167
+ * The single source of truth for that summary. BOTH surfaces render it the
168
+ * same way (see StatusMark): a circle, or a tree in a linked worktree,
169
+ * filled with the colour below.
124
170
  */
125
- function badgeDot(info) {
171
+ function badgeStatus(info) {
126
172
  const dirty = info.dirty === true;
127
173
  const behind = (info.behind || 0) > 0;
128
174
  const ahead = (info.ahead || 0) > 0;
129
175
  const conflict = (info.unmergedFiles || 0) > 0;
130
- if (conflict || (dirty && behind)) return "\uD83D\uDD34";
131
- if (dirty || ahead || behind) return "\uD83D\uDFE1";
132
- return "\uD83D\uDFE2";
176
+ if (conflict || (dirty && behind)) return "error";
177
+ if (dirty || ahead || behind) return "warn";
178
+ return "ok";
133
179
  }
134
180
 
135
181
  /**
136
- * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row badge shows
137
- * status + branch alone; the chip is the detailed surface. Rule set:
182
+ * Mark fill per status, from the app's own semantic tokens so BOTH surfaces
183
+ * follow light/dark and custom themes — which a hardcoded emoji cannot do.
184
+ * The literals are only fallbacks for use outside DSH.
185
+ */
186
+ const MARK_FILL = {
187
+ error: "var(--dsw-alias-state-error-primary, #e5484d)",
188
+ warn: "var(--dsw-alias-state-warn-primary, #d29922)",
189
+ ok: "var(--dsw-alias-state-success-primary, #30a46c)"
190
+ };
191
+
192
+ /**
193
+ * Accessible name per status, WITHOUT the "git" prefix — the mark adds
194
+ * "git: " or "git worktree: ", so shape and colour are never the only
195
+ * channel for either fact.
196
+ */
197
+ const STATUS_LABEL = {
198
+ error: "conflict, or uncommitted changes on an outdated base",
199
+ warn: "uncommitted changes, or out of sync with upstream",
200
+ ok: "clean and in sync"
201
+ };
202
+
203
+ /**
204
+ * Sync/dirty suffix — INPUT CHIP ONLY. The sidebar row shows status alone;
205
+ * the chip is the surface that carries the numbers. Rule set:
138
206
  * - dirty files render as ✎n (pencil = worktree files, distinct from
139
207
  * the ↑/↓ commit-sync axis)
140
208
  * - while dirty, BOTH sync counts render including zeros (↑0 ↓2 ✎3),
@@ -154,6 +222,68 @@ window.__ModuleLoader__.load({
154
222
  return parts.length > 0 ? " " + parts.join(" ") : "";
155
223
  }
156
224
 
225
+ /**
226
+ * In-progress operation token — INPUT CHIP ONLY (the row shows the status
227
+ * tree alone). A paused rebase or cherry-pick whose conflicts are all
228
+ * already staged has no unmerged files, so the three-state summary cannot
229
+ * distinguish it from ordinary dirty work; this says which
230
+ * history-rewriting operation git is waiting on.
231
+ */
232
+ const OPERATION_LABELS = {
233
+ merge: "\u2694merge",
234
+ squash: "\u2694squash",
235
+ "cherry-pick": "\u2694cherry-pick",
236
+ revert: "\u2694revert",
237
+ bisect: "\u2694bisect",
238
+ rebase: "\u2694rebase",
239
+ sequencer: "\u2694sequencer"
240
+ };
241
+
242
+ /** ` ⚔rebase` when an operation is paused, else "". */
243
+ function formatOperationToken(info) {
244
+ const label = OPERATION_LABELS[info.operation];
245
+ return label === void 0 ? "" : " " + label;
246
+ }
247
+
248
+ /**
249
+ * Linked-worktree NAME token — INPUT CHIP ONLY, and only the name: worktree-
250
+ * ness itself is carried by the status mark's shape (a tree instead of a
251
+ * circle) on both surfaces. The chip has no other workspace identity, so
252
+ * several worktrees of one repository would otherwise render identical chips
253
+ * with no way to tell which checkout a conversation is in. The node half
254
+ * reports whether this is a linked worktree plus its directory NAME (never a
255
+ * path).
256
+ *
257
+ * The name is appended only when it says something the branch does not: the
258
+ * usual `repo-feat-x` directory sitting on branch `feat-x` would otherwise
259
+ * read as stutter. (The ROW needs no such rule — it shows the name instead
260
+ * of the branch, so there is nothing to stutter against.)
261
+ */
262
+
263
+ /**
264
+ * True when the worktree name is already implied by the branch name, so
265
+ * showing both is pure repetition. Compared on a normalized form —
266
+ * case-folded, with `/` and `_` folded to `-` — because directory and
267
+ * branch conventions differ without changing the meaning (`feat/x` in a
268
+ * `repo-feat-x` directory).
269
+ */
270
+ function worktreeNameIsRedundant(name, branch) {
271
+ if (typeof name !== "string" || name === "" || typeof branch !== "string") return true;
272
+ const normalize = (value) => value.toLowerCase().replace(/[/_]/g, "-");
273
+ const normalizedName = normalize(name);
274
+ const normalizedBranch = normalize(branch);
275
+ return normalizedName === normalizedBranch
276
+ || normalizedName.endsWith("-" + normalizedBranch)
277
+ || normalizedBranch.endsWith("-" + normalizedName);
278
+ }
279
+
280
+ /** ` name` for a worktree whose name the branch does not already say, else "". */
281
+ function formatWorktreeToken(info) {
282
+ if (info.isWorktree !== true) return "";
283
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
284
+ return worktreeNameIsRedundant(name, info.branch) ? "" : " " + name;
285
+ }
286
+
157
287
  const META_STYLE = {
158
288
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
159
289
  fontSize: "12px",
@@ -163,66 +293,122 @@ window.__ModuleLoader__.load({
163
293
  };
164
294
 
165
295
  /**
166
- * Row badge — three-state dot + branch (sync/dirty detail lives on
167
- * the input chip):
168
- * the_paragliding_app | 🟢 main
169
- * Dot color via badgeDot(): green clean+synced, yellow dirty or
170
- * out-of-sync, red conflict or dirty-and-behind.
171
- * Always renders the workspace name (so the row keeps its identity);
172
- * appends the muted `| emoji branch` part only for git workspaces.
296
+ * Status mark, SHARED BY BOTH SURFACES: the same 12px shape in the same fill
297
+ * wherever it appears, so the sidebar row and the input chip read
298
+ * identically. The FILL is the status colour; the SHAPE carries
299
+ * worktree-ness — a circle for a main checkout, a tree for a linked
300
+ * worktree — so the two facts never compete for the same channel.
301
+ *
302
+ * Drawn rather than typed because 🌳 is a COLOUR EMOJI: CSS cannot tint
303
+ * its leaves, and a mark that carries the status has to be paintable.
304
+ * Three overlapping crown lobes plus a trunk stay legible at this size.
305
+ *
306
+ * Neither colour nor shape is the only channel: the accessible name
307
+ * states both.
173
308
  */
174
- function WorkspaceGitBadge({ label, cwd }) {
175
- const info = useGitStatus(cwd, false);
176
- const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
177
- if (cwd !== void 0 && info !== void 0 && info.git === true) {
178
- children.push(
179
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, margin: "0 7px" }, children: "|" }),
180
- react_jsx_runtime.jsx("span", { style: { ...META_STYLE, marginRight: "4px" }, children: badgeDot(info) }),
181
- react_jsx_runtime.jsx("span", { style: META_STYLE, children: info.branch })
182
- );
309
+ function StatusMark({ info }) {
310
+ const status = badgeStatus(info);
311
+ const fill = MARK_FILL[status];
312
+ const isWorktree = info.isWorktree === true;
313
+ const svg = {
314
+ // 14px, NOT 12px: a colour emoji draws well above its nominal size — the
315
+ // 🔴/🟡/🟢 dot this replaces put down ~13-14px of ink at the surfaces'
316
+ // 12px font-size, so a 12px box read as a shrunken dot.
317
+ width: 14,
318
+ height: 14,
319
+ viewBox: "0 0 12 12",
320
+ role: "img",
321
+ "aria-label": (isWorktree ? "git worktree: " : "git: ") + STATUS_LABEL[status],
322
+ focusable: "false",
323
+ style: { flex: "none", display: "block", color: META_STYLE.color }
324
+ };
325
+ if (!isWorktree) {
326
+ // a plain filled disc: this is a main checkout. It fills its box so the
327
+ // ink lands at the size the emoji dot used to.
328
+ return react_jsx_runtime.jsx("svg", { ...svg, children: react_jsx_runtime.jsx("circle", { cx: 6, cy: 6, r: 5.5, fill }) });
183
329
  }
184
- return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0 }, children });
330
+ return react_jsx_runtime.jsxs("svg", {
331
+ ...svg,
332
+ children: [
333
+ // trunk first, so the crown's lobes overlap and join it. These are the
334
+ // 12px-era coordinates scaled 1.15 about the tree's centre, so its ink
335
+ // stays comparable to the disc's instead of reading small beside it.
336
+ react_jsx_runtime.jsx("rect", { key: "trunk", x: 5.25, y: 7.3, width: 1.5, height: 4.14, rx: 0.63, fill: "currentColor" }),
337
+ react_jsx_runtime.jsx("circle", { key: "crown-1", cx: 6, cy: 3.62, r: 3.57, fill }),
338
+ react_jsx_runtime.jsx("circle", { key: "crown-2", cx: 3.24, cy: 5.46, r: 2.53, fill }),
339
+ react_jsx_runtime.jsx("circle", { key: "crown-3", cx: 8.76, cy: 5.46, r: 2.53, fill })
340
+ ]
341
+ });
185
342
  }
186
343
 
187
- //#region composer chip (fallback + companion surface: conversation.composer.dock)
188
- /** Client root ctx, captured at apply() for the chip's service lookups. */
189
- let clientCtx = null;
344
+ /**
345
+ * A linked worktree's directory name, or null when there is nothing to
346
+ * show. A main checkout shows NO text: the row surveys status and identity,
347
+ * and the branch lives on the input chip — which is why the branch is never
348
+ * rendered here. The name is the one thing the row's own label (the
349
+ * worktree directory by default) cannot be trusted to say once a workspace
350
+ * has been renamed.
351
+ */
352
+ function rowName(info) {
353
+ if (info.isWorktree !== true) return null;
354
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
355
+ return name === "" ? null : name;
356
+ }
190
357
 
191
358
  /**
192
- * Resolve the workspace cwd attached to a session: projects the
193
- * workspaces service's snapshot store (items carry path + sessionIds)
194
- * and re-resolves on change. Returns undefined while unknown.
359
+ * Row badge — workspace name on the left, the status mark floated right:
360
+ * the_paragliding_app ●
361
+ * worktree_thing hotfix-tree 🌳
362
+ * Fill colour via badgeStatus(): green clean+synced, amber dirty or
363
+ * out-of-sync, red conflict or dirty-and-behind. Shape via the node half's
364
+ * isWorktree: circle = main checkout, tree = linked worktree.
365
+ * Always renders the workspace name (so the row keeps its identity) and
366
+ * appends the right-hand mark only for git workspaces. No branch, and no
367
+ * `|` separator.
368
+ *
369
+ * Targets the row's `workspaceId`. The seam also passes `cwd`, which is
370
+ * deliberately ignored: the node half resolves the directory itself, so
371
+ * the client never has to name one. A row with no workspaceId (the
372
+ * ungrouped bucket) has no workspace to report on, so it renders
373
+ * name-only.
195
374
  */
196
- function useSessionWorkspaceCwd(sessionId) {
197
- const workspaces = clientCtx === null ? null : clientCtx.get("workspaces");
198
- const resolve = () => {
199
- if (sessionId === void 0 || workspaces === void 0) return void 0;
200
- const item = workspaces.list.getSnapshot().items.find((w) => w.sessionIds !== void 0 && w.sessionIds.includes(sessionId));
201
- return item === void 0 ? void 0 : item.path;
202
- };
203
- const [cwd, setCwd] = react.useState(resolve);
204
- react.useEffect(() => {
205
- if (sessionId === void 0 || workspaces === void 0) return;
206
- setCwd(resolve());
207
- return workspaces.list.subscribe(() => {
208
- setCwd(resolve());
209
- });
210
- }, [sessionId, workspaces]);
211
- return cwd;
375
+ function WorkspaceGitBadge({ label, workspaceId }) {
376
+ // no detail=1: the row needs status and identity only, and the extra
377
+ // log / stash calls have no consumer yet
378
+ const info = useGitStatus(workspaceId === void 0 ? void 0 : { kind: "workspace", id: workspaceId });
379
+ const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
380
+ if (workspaceId !== void 0 && info !== void 0 && info.git === true) {
381
+ // marginLeft:auto floats the mark to the right edge, so the status
382
+ // shape holds a fixed right-hand column and a worktree's name grows
383
+ // leftwards from it instead of shoving the shape around.
384
+ const meta = [];
385
+ const name = rowName(info);
386
+ if (name !== null) meta.push(react_jsx_runtime.jsx("span", { key: "name", style: META_STYLE, children: name }));
387
+ meta.push(react_jsx_runtime.jsx(StatusMark, { key: "mark", info }));
388
+ children.push(react_jsx_runtime.jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", marginLeft: "auto", paddingLeft: "8px" }, children: meta }));
389
+ }
390
+ return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
212
391
  }
213
392
 
393
+ //#region composer chip (upstream additive surface: conversation.input.left)
214
394
  /**
215
- * Chip line docked at the composer: git state of the workspace the
216
- * CURRENT conversation is attached to. Works on unpatched installs
217
- * (conversation.composer.dock is an upstream additive slot), and stays
218
- * useful next to the sidebar rows on patched/upstream-seam installs
219
- * because it is context-anchored ("where am I") rather than surveying.
395
+ * Chip line in the input row: git state of the workspace the CURRENT
396
+ * conversation is attached to. It targets the session id and lets the node
397
+ * half resolve the workspace, so this surface needs no `workspaces`
398
+ * service, no cwd and no path plumbing at all. Works on unpatched installs
399
+ * (conversation.input.left is an upstream additive slot), and stays useful
400
+ * next to the sidebar rows because it is context-anchored ("where am I")
401
+ * rather than surveying.
220
402
  */
221
403
  function ComposerGitChip({ sessionId }) {
222
- const cwd = useSessionWorkspaceCwd(sessionId);
223
- const info = useGitStatus(cwd, true);
224
- if (cwd === void 0 || info === void 0 || info.git !== true) return null;
225
- const text = badgeDot(info) + " " + info.branch + formatGitSuffix(info);
404
+ // no detail=1: nothing renders lastCommits / stashCount yet, and asking
405
+ // for them would add a log -3 plus a stash list to every refresh
406
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId });
407
+ if (info === void 0 || info.git !== true) return null;
408
+ // the mark is an element now rather than a leading glyph in the string, so
409
+ // the SAME StatusMark the sidebar row draws carries the status here too;
410
+ // the container's 4px gap supplies the space the emoji's own did
411
+ const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
226
412
  return react_jsx_runtime.jsx("span", {
227
413
  style: {
228
414
  display: "inline-flex",
@@ -235,28 +421,46 @@ window.__ModuleLoader__.load({
235
421
  whiteSpace: "nowrap",
236
422
  cursor: "default"
237
423
  },
238
- children: text
424
+ children: [
425
+ react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
426
+ react_jsx_runtime.jsx("span", { key: "text", children: text })
427
+ ]
239
428
  });
240
429
  }
241
430
  //#endregion
242
431
 
243
- const inject = ["slots", "workspaces"];
432
+ // `workspaces` is no longer required: both surfaces target an id and the
433
+ // node half resolves the workspace, so the client never needs a service
434
+ // lookup. Fewer declared services also means fewer ways to fail to load.
435
+ const inject = ["slots"];
244
436
 
245
437
  /**
246
438
  * Register the badge into both seams. The seam owner hands each entry the
247
- * row owner share as props; the badge destructures { workspaceId, cwd,
248
- * label } from it.
439
+ * row owner share as props; the badge destructures { workspaceId, label }
440
+ * and deliberately ignores the cwd it is also given.
249
441
  */
250
442
  function apply(ctx) {
251
- clientCtx = ctx;
252
443
  // inject() re-evaluates when a seam's declaration appears, so boot
253
444
  // order relative to the workspace browser does not matter. On an
254
- // unpatched install the row seams never get declared, so those two
255
- // registrations simply never render anything.
256
- ctx.slots.inject("sidebar.workspaces.row", () => ctx.slots.register({
257
- name: "sidebar.workspaces.row",
258
- id: "git-badge"
259
- }, WorkspaceGitBadge));
445
+ // unpatched install the row seam is never declared, so this callback
446
+ // never fires and the row badge simply never renders.
447
+ //
448
+ // The registration is reported from INSIDE the callback on purpose: a
449
+ // `spec()` check here at apply() time runs before the workspace browser
450
+ // declares the seam, so it reports the boot race rather than the
451
+ // outcome. That mistake made the old one-shot line claim "sidebar rows
452
+ // = off" while five row badges were rendering.
453
+ let rowsReported = false;
454
+ ctx.slots.inject("sidebar.workspaces.row", () => {
455
+ if (!rowsReported) {
456
+ rowsReported = true;
457
+ console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
458
+ }
459
+ return ctx.slots.register({
460
+ name: "sidebar.workspaces.row",
461
+ id: "git-badge"
462
+ }, WorkspaceGitBadge);
463
+ });
260
464
  // Input-row chip: upstream additive slot rendered in the input bar's
261
465
  // leading cluster, right after the access picker — the git state sits
262
466
  // with the controls that govern the conversation. Present on every
@@ -269,7 +473,7 @@ window.__ModuleLoader__.load({
269
473
  inject: (sessionId) => ({ sessionId })
270
474
  }, ComposerGitChip));
271
475
  const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
272
- console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)" : "off (seam absent — sidebar badges need the sidebar.workspaces.row seam)") + ".");
476
+ console.info("[dsh-git-badge] surfaces: input chip = on; sidebar rows = " + (seamDeclared ? "on (seam present)." : "awaiting the sidebar.workspaces.row seam — the line above reports it if it appears."));
273
477
  }
274
478
 
275
479
  exports.apply = apply;