dsh-git-badge 0.7.0 → 0.9.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
@@ -6,12 +6,23 @@
6
6
  * identically wherever they appear.
7
7
  *
8
8
  * Surfaces:
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.
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.workspaces.sessionRow.detail (seam): the hover-card line that names
14
+ * that checkout, rendered inside the card upstream ALREADY shows for a session
15
+ * row rather than in a tooltip nested inside it.
12
16
  * - conversation.input.left (upstream): chip — status mark + branch + a
13
- * worktree's name + in-progress operation token + sync/dirty suffix
14
- * The hover card is intentionally untouched.
17
+ * worktree's name + in-progress operation token + sync/dirty suffix + the
18
+ * PR/CI token, with a HOVER CARD carrying the breakdown the chip has no room
19
+ * for (file detail, recent commits, stash, PR state, and the conversation's own
20
+ * checkout when the badge followed a worktree).
21
+ *
22
+ * 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.
15
26
  */
16
27
  window.__ModuleLoader__.load({
17
28
  id: "dsh-git-badge",
@@ -22,6 +33,24 @@ window.__ModuleLoader__.load({
22
33
  let react_jsx_runtime = require("react/jsx-runtime");
23
34
  let react = require("react");
24
35
 
36
+ /**
37
+ * Shell-seeded UI primitives. `Tooltip` is handed to EVERY client bundle
38
+ * by the web shell itself — it needs no `dsh.client.inject` entry and no
39
+ * npm dependency (dsh-client-ui-open-in-app requires it the same way).
40
+ *
41
+ * Guarded, because the seed table belongs to the shell and not to this
42
+ * plugin: on a shell that does not provide it the chip must still render,
43
+ * just without the hover card. A missing card is a smaller failure than a
44
+ * missing badge.
45
+ */
46
+ let primitives = null;
47
+ try {
48
+ primitives = require("@deepseek-ai/dsh-client-ui-primitives");
49
+ } catch {
50
+ primitives = null;
51
+ }
52
+ const Tooltip = primitives === null ? void 0 : primitives.Tooltip;
53
+
25
54
  /** Module-level git-status cache. Entries are invalidated by SSE events, never by age. */
26
55
  const GIT_CACHE = new Map();
27
56
  /** In-flight fetches per cacheKey: bursts collapse into one request. */
@@ -35,11 +64,21 @@ window.__ModuleLoader__.load({
35
64
  * one. The chip targets its conversation (`session`); a sidebar row
36
65
  * targets the workspace id the seam hands it. The query string doubles as
37
66
  * the cache key.
67
+ *
68
+ * The two optional additions are the INPUT CHIP's alone, and each is an
69
+ * explicit request rather than something every badge pays for:
70
+ * - `detail=1` — recent commits and stash for the hover card;
71
+ * - `pr=1` — the branch's GitHub PR/CI state.
72
+ * A sidebar row asks for neither, so surveying N workspaces never costs N
73
+ * forge calls (see the node half's PR region).
38
74
  */
39
- function targetQuery(target) {
75
+ function targetQuery(target, options) {
40
76
  if (target === void 0 || target.id === void 0) return void 0;
41
77
  const param = target.kind === "workspace" ? "workspace" : "session";
42
- return param + "=" + encodeURIComponent(target.id);
78
+ let query = param + "=" + encodeURIComponent(target.id);
79
+ if (options?.pr === true) query += "&pr=1";
80
+ if (options?.detail === true) query += "&detail=1";
81
+ return query;
43
82
  }
44
83
 
45
84
  //#region SSE change feed (one EventSource per page, ref-counted)
@@ -90,16 +129,24 @@ window.__ModuleLoader__.load({
90
129
  * node half's watcher reports a change for this target (SSE), plus a slow
91
130
  * safety-net poll in case the stream dies silently. Returns undefined
92
131
  * while loading and for non-git workspaces.
132
+ *
133
+ * `options.detail` / `options.pr` ask the node half for the chip's extras
134
+ * (see targetQuery). `options.enabled: false` holds the fetch back
135
+ * entirely — that is what makes the hover card's extra fields LAZY: the
136
+ * chip renders without them and only starts paying for `log -3` plus a
137
+ * stash list once a pointer actually rests on it. The hook is still called
138
+ * unconditionally (hooks may not be conditional); the GATE is inside.
93
139
  */
94
- function useGitStatus(target) {
95
- const cacheKey = targetQuery(target);
140
+ function useGitStatus(target, options) {
141
+ const enabled = options?.enabled !== false;
142
+ const cacheKey = targetQuery(target, options);
96
143
  const hit = cacheKey === void 0 ? void 0 : GIT_CACHE.get(cacheKey);
97
144
  // state carries the key it belongs to: switching conversations changes
98
145
  // the key, and showing the previous session's badge until the new fetch
99
146
  // lands would be wrong
100
147
  const [state, setState] = react.useState({ key: cacheKey, data: hit === void 0 ? void 0 : hit.data });
101
148
  react.useEffect(() => {
102
- if (cacheKey === void 0) return;
149
+ if (cacheKey === void 0 || !enabled) return;
103
150
  let alive = true;
104
151
  // The workspace this target resolved to, taken from the response (the
105
152
  // server echoes it). THIS is what events are matched against: an event
@@ -149,7 +196,7 @@ window.__ModuleLoader__.load({
149
196
  unsubscribe();
150
197
  clearInterval(fallback);
151
198
  };
152
- }, [cacheKey]);
199
+ }, [cacheKey, enabled]);
153
200
  return state.key === cacheKey ? state.data : void 0;
154
201
  }
155
202
 
@@ -222,6 +269,57 @@ window.__ModuleLoader__.load({
222
269
  return parts.length > 0 ? " " + parts.join(" ") : "";
223
270
  }
224
271
 
272
+ /**
273
+ * PR / CI token — INPUT CHIP ONLY, and absent entirely when the node half
274
+ * has nothing to say (no `gh`, no GitHub remote, no PR for the branch), so
275
+ * a machine without `gh` sees precisely the chip it saw before.
276
+ *
277
+ * The state glyph is not the only channel: the token carries an accessible
278
+ * name spelling the state out, the same rule the status mark follows.
279
+ */
280
+ const PR_STATE_GLYPH = { passing: "\u2713", pending: "\u2026", failing: "\u2717", none: "" };
281
+ const PR_STATE_LABEL = { passing: "checks passing", pending: "checks running", failing: "checks failing", none: "no checks" };
282
+
283
+ /** ` PR#142 ✗` (plus ` draft`), or "" when there is no PR to report. */
284
+ function formatPrToken(info) {
285
+ const pr = info.pr;
286
+ if (pr === void 0 || pr === null || pr.number === void 0) return "";
287
+ const state = PR_STATE_GLYPH[pr.state] === void 0 ? "none" : pr.state;
288
+ const glyph = PR_STATE_GLYPH[state];
289
+ return " PR#" + pr.number + (glyph === "" ? "" : " " + glyph) + (pr.draft === true ? " draft" : "");
290
+ }
291
+
292
+ /** The token's accessible name: "pull request 142, checks failing, draft". */
293
+ function prTokenLabel(info) {
294
+ const pr = info.pr;
295
+ if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
296
+ const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
297
+ return "pull request " + pr.number + ", " + state + (pr.draft === true ? ", draft" : "");
298
+ }
299
+
300
+ /**
301
+ * The PR's web URL, or undefined — the token is a LINK only when the node
302
+ * half vouched for a URL, and is plain text otherwise (no `gh`, no GitHub
303
+ * remote, an unauthenticated `gh`: the token still appears, it just does not
304
+ * pretend to be clickable, so there is no dead link to discover).
305
+ *
306
+ * The protocol is re-checked HERE even though the node half already dropped
307
+ * a non-http(s) value: an `href` is the one place a payload string becomes
308
+ * executable, so the element that creates it verifies its own input rather
309
+ * than trusting an upstream guard to stay in place. The shell's own markdown
310
+ * renderer guards its links the same way.
311
+ */
312
+ function prLinkUrl(info) {
313
+ const pr = info.pr;
314
+ if (pr === void 0 || pr === null || typeof pr.url !== "string") return void 0;
315
+ try {
316
+ const { protocol } = new URL(pr.url);
317
+ return protocol === "http:" || protocol === "https:" ? pr.url : void 0;
318
+ } catch {
319
+ return void 0;
320
+ }
321
+ }
322
+
225
323
  /**
226
324
  * In-progress operation token — INPUT CHIP ONLY (the row shows the status
227
325
  * tree alone). A paused rebase or cherry-pick whose conflicts are all
@@ -246,43 +344,12 @@ window.__ModuleLoader__.load({
246
344
  }
247
345
 
248
346
  /**
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).
347
+ * NOTE: a worktree's NAME is deliberately NOT part of the chip's text. It is
348
+ * long, it competes with the branch for the same glance, and the chip already
349
+ * says THAT the checkout is a worktree through the mark's shape. WHICH tree it
350
+ * is belongs to the hover card's `worktree` row — see worktreeDetail. (The
351
+ * old append-unless-redundant rule lived here; it is gone with the name.)
269
352
  */
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
353
 
287
354
  const META_STYLE = {
288
355
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
@@ -293,7 +360,7 @@ window.__ModuleLoader__.load({
293
360
  };
294
361
 
295
362
  /**
296
- * Status mark, SHARED BY BOTH SURFACES: the same 12px shape in the same fill
363
+ * Status mark, SHARED BY BOTH SURFACES: the same shape in the same fill
297
364
  * wherever it appears, so the sidebar row and the input chip read
298
365
  * identically. The FILL is the status colour; the SHAPE carries
299
366
  * worktree-ness — a circle for a main checkout, a tree for a linked
@@ -318,7 +385,12 @@ window.__ModuleLoader__.load({
318
385
  height: 14,
319
386
  viewBox: "0 0 12 12",
320
387
  role: "img",
321
- "aria-label": (isWorktree ? "git worktree: " : "git: ") + STATUS_LABEL[status],
388
+ "aria-label":
389
+ (isWorktree ? "git worktree: " : "git: ") +
390
+ STATUS_LABEL[status] +
391
+ // an inferred checkout is not where the conversation lives, so the
392
+ // accessible name says where the fact came from
393
+ (info.worktreeInferred === true ? " (inferred from an open pull request in this repository)" : ""),
322
394
  focusable: "false",
323
395
  style: { flex: "none", display: "block", color: META_STYLE.color }
324
396
  };
@@ -342,52 +414,271 @@ window.__ModuleLoader__.load({
342
414
  }
343
415
 
344
416
  /**
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.
417
+ * The ONE thing this conversation needs from you, or "" when it needs
418
+ * nothing.
419
+ *
420
+ * A session row's job is TRIAGE — "does this need me?" — so this is not the
421
+ * chip's status restated: it is a short imperative, present only when an
422
+ * action is genuinely warranted, first match wins. Silence is the common and
423
+ * correct answer: uncommitted work is a state rather than a chore, and DRAFT,
424
+ * BLOCKED, BEHIND, UNSTABLE and UNKNOWN are things to wait for, not to do.
425
+ *
426
+ * `merge` keys on GitHub's OWN verdict (`mergeStateStatus === "CLEAN"`) rather
427
+ * than a checks-plus-reviews judgement assembled here: mergeability depends on
428
+ * branch protection and required reviews, which is the forge's business. A PR
429
+ * read that could not answer has no `mergeState`, so it stays silent.
351
430
  */
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;
431
+ function actionToken(info) {
432
+ if (info === void 0 || info === null || info.git !== true) return "";
433
+ if ((info.unmergedFiles || 0) > 0) return "resolve";
434
+ const pr = info.pr === void 0 || info.pr === null ? void 0 : info.pr;
435
+ if (pr !== void 0) {
436
+ if (pr.state === "failing") return "fix CI";
437
+ if (pr.review === "CHANGES_REQUESTED") return "review";
438
+ if (pr.mergeState === "CLEAN") return "merge";
439
+ }
440
+ // diverged counts as behind: reconciling is the action either way
441
+ if ((info.behind || 0) > 0) return "pull";
442
+ if ((info.ahead || 0) > 0) return "push";
443
+ return "";
444
+ }
445
+
446
+ /**
447
+ * Session-row badge — the ACTION, and nothing else:
448
+ * Session title… merge
449
+ *
450
+ * An experiment in what a sidebar row is FOR. It used to carry the chip's mark
451
+ * plus a PR token; it now answers exactly one question — does this conversation
452
+ * need me? — in one word, and says nothing otherwise. The chip keeps the mark,
453
+ * the branch, the counts and the PR/CI token, because that is the surface for
454
+ * "where am I, and what is the state".
455
+ *
456
+ * Consequences, all deliberate: the row no longer shows local status (clean /
457
+ * dirty / ahead / behind) at a glance, no longer carries the worktree's tree
458
+ * SHAPE, and a silent row cannot distinguish "nothing to do" from "no PR" or
459
+ * "no gh". Those answers live in the hover line below, which also names the
460
+ * pull request the action refers to.
461
+ *
462
+ * Targets the row's `sessionId`: the node half resolves the session to the
463
+ * checkout it is working in. `workspaceId` arrives in the owner share and is
464
+ * deliberately unused — the flat and search lists render without one, which is
465
+ * what keeps badges off them.
466
+ *
467
+ * `pr: true` is what makes an action possible at all (`merge` especially). It
468
+ * costs one forge read per (repository, branch) per TTL, and the detail line
469
+ * below reuses THIS request rather than issuing its own.
470
+ */
471
+ /**
472
+ * Action -> colour. Colour here means SEVERITY — how much this needs you —
473
+ * rather than identity: a broken build and a conflict are the loud ones, a
474
+ * requested review is a nudge, `merge` is the all-clear, and routine sync
475
+ * stays quiet so the loud rows keep meaning something. The WORD remains the
476
+ * channel (colour only reinforces it), and the tokens are the app's own, so
477
+ * both themes work.
478
+ */
479
+ const ACTION_COLOUR = {
480
+ resolve: "var(--dsw-alias-state-error-primary, #e5484d)",
481
+ "fix CI": "var(--dsw-alias-state-error-primary, #e5484d)",
482
+ review: "var(--dsw-alias-state-warn-primary, #d29922)",
483
+ merge: "var(--dsw-alias-state-success-primary, #30a46c)",
484
+ pull: "var(--dsw-alias-label-secondary, #5b6570)",
485
+ push: "var(--dsw-alias-label-secondary, #5b6570)"
486
+ };
487
+
488
+ /**
489
+ * The action's own style, on top of META_STYLE:
490
+ * - `marginLeft: auto` floats it right, so a column of actions lines up down
491
+ * the sidebar — that alignment is what makes the list scannable;
492
+ * - `marginRight` keeps it clear of the relative time beside it (the first cut
493
+ * sat flush and read `merge11m`);
494
+ * - weight 500 rather than the timestamp's own treatment, because an action
495
+ * rendered in tertiary grey reads as metadata.
496
+ */
497
+ const ACTION_STYLE = { flex: "none", marginLeft: "auto", marginRight: "8px", fontWeight: 500 };
498
+
499
+ /** The action badge: floated, weighted, and coloured by severity. */
500
+ function SessionGitBadge({ sessionId }) {
501
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
502
+ if (sessionId === void 0 || info === void 0 || info.git !== true) return null;
503
+ const action = actionToken(info);
504
+ if (action === "") return null;
505
+ const colour = ACTION_COLOUR[action];
506
+ return react_jsx_runtime.jsx("span", {
507
+ style: colour === void 0 ? { ...META_STYLE, ...ACTION_STYLE } : { ...META_STYLE, ...ACTION_STYLE, color: colour },
508
+ children: action
509
+ });
356
510
  }
357
511
 
358
512
  /**
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.
513
+ * Session-row hover-card line — everything the action word cannot say: which
514
+ * checkout the row's status describes, and which pull request the action
515
+ * refers to. It renders through the sessionRow.detail seam, inside the card
516
+ * upstream already opens for a row.
517
+ *
518
+ * This matters MORE now that the row shows only an imperative: `merge` with no
519
+ * way to see merge WHAT would be a nag rather than a hint, so the line appears
520
+ * whenever there is something to name — a worktree, a pull request, or both.
521
+ *
522
+ * It makes no request of its own: the same `{ pr: true }` query as the badge
523
+ * above resolves to the same cache key, so a card that mounts for every row
524
+ * still costs one fetch per row, and a cold cache is served by the badge's
525
+ * own in-flight request. That is why the badge must keep asking for `pr=1`.
368
526
  *
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.
527
+ * Only a WORKTREE's checkout is spelled out. For a main checkout the branch is
528
+ * the ordinary case and repeating it on every session's card would be noise;
529
+ * a tree, by contrast, is exactly what the row's own title cannot tell you.
530
+ */
531
+ function SessionGitDetail({ sessionId }) {
532
+ const info = useGitStatus(sessionId === void 0 ? void 0 : { kind: "session", id: sessionId }, { pr: true });
533
+ if (info === void 0 || info.git !== true) return null;
534
+ const bits = [];
535
+ if (info.isWorktree === true) {
536
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
537
+ const checkout = [name === "" ? String(info.branch) : name + " on " + info.branch];
538
+ // the node half followed this tree because it is the one whose branch
539
+ // has the open PR — say so, because nothing on the row can
540
+ if (info.worktreeInferred === true) checkout.push("its branch has the open pull request");
541
+ bits.push("checkout: " + checkout.join(" \u00B7 "));
542
+ }
543
+ const pr = formatPrDetail(info.pr);
544
+ if (pr !== void 0) bits.push("pull request " + pr);
545
+ return bits.length === 0 ? null : react_jsx_runtime.jsx("span", { style: META_STYLE, children: bits.join(" \u00B7 ") });
546
+ }
547
+
548
+ /**
549
+ * The chip's `✎n` is one number for four different things; the card splits
550
+ * it, so "ready to commit" is distinguishable from "not staged yet". The
551
+ * collapsed caveat is the node half's own honesty flag: when the untracked
552
+ * walk blew its budget the count is a DIRECTORY count, and saying so beats
553
+ * presenting an under-count as exact.
554
+ */
555
+ function formatFileBreakdown(info) {
556
+ const parts = [];
557
+ if ((info.stagedFiles || 0) > 0) parts.push(info.stagedFiles + " staged");
558
+ if ((info.unstagedFiles || 0) > 0) parts.push(info.unstagedFiles + " unstaged");
559
+ if ((info.unmergedFiles || 0) > 0) parts.push(info.unmergedFiles + " unmerged");
560
+ const untracked = info.untrackedFiles || 0;
561
+ if (untracked > 0) parts.push(untracked + " untracked" + (info.untrackedMode === "collapsed" ? " (collapsed)" : ""));
562
+ return parts.length === 0 ? "clean" : parts.join(" \u00B7 ");
563
+ }
564
+
565
+ /** The PR's full state as text, for the card: "#142 · checks failing · draft · review approved". */
566
+ function formatPrDetail(pr) {
567
+ if (pr === void 0 || pr === null || pr.number === void 0) return void 0;
568
+ const state = PR_STATE_LABEL[pr.state] === void 0 ? PR_STATE_LABEL.none : PR_STATE_LABEL[pr.state];
569
+ const bits = ["#" + pr.number, state];
570
+ if (pr.draft === true) bits.push("draft");
571
+ if (pr.review !== void 0) bits.push(String(pr.review).toLowerCase().replace(/_/g, " "));
572
+ if (pr.open === false) bits.push("not open");
573
+ return bits.join(" \u00B7 ");
574
+ }
575
+
576
+ /**
577
+ * The `checkout` row: the directory the CONVERSATION itself names, present
578
+ * only when the badge is describing an inferred worktree instead. The chip's
579
+ * branch and counts are the tree's in that case, so without this row the
580
+ * checkout's own state is nowhere on screen. Branch, then files (or "clean"),
581
+ * then sync counts only when they are nonzero — the chip's own rule.
374
582
  */
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 }));
583
+ function formatCheckoutDetail(checkout) {
584
+ if (checkout === void 0 || checkout === null || typeof checkout.branch !== "string") return void 0;
585
+ const files = (checkout.changedFiles || 0) + (checkout.untrackedFiles || 0);
586
+ const bits = [checkout.branch, files > 0 ? "\u270E" + files : "clean"];
587
+ if ((checkout.ahead || 0) > 0 || (checkout.behind || 0) > 0) {
588
+ bits.push("\u2191" + (checkout.ahead || 0) + " \u2193" + (checkout.behind || 0));
389
589
  }
390
- return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
590
+ return bits.join(" \u00B7 ");
591
+ }
592
+
593
+ /**
594
+ * The `worktree` row: WHICH linked worktree the badge is describing. The chip
595
+ * names only the branch (see the note above formatOperationToken), so the
596
+ * card is the one place a tree is identified — the mark's shape can only say
597
+ * *that* the checkout is a worktree, never which. An inferred follow also says
598
+ * why, because "which tree" and "why this tree" are different questions and
599
+ * the second is the one a reader will ask.
600
+ */
601
+ function worktreeDetail(info) {
602
+ if (info === void 0 || info === null || info.isWorktree !== true) return void 0;
603
+ const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
604
+ if (name === "") return void 0;
605
+ return info.worktreeInferred === true ? name + " (inferred from its open pull request)" : name;
606
+ }
607
+
608
+ const CARD_CONTAINER = {
609
+ display: "flex",
610
+ flexDirection: "column",
611
+ gap: "4px",
612
+ fontSize: "12px",
613
+ lineHeight: "18px",
614
+ maxWidth: "340px"
615
+ };
616
+ const CARD_ROW = { display: "flex", gap: "8px", alignItems: "baseline" };
617
+ const CARD_LABEL = { color: "var(--dsw-alias-label-tertiary, #9ea7ad)", flex: "none", minWidth: "62px" };
618
+ const CARD_VALUE = { minWidth: 0, overflowWrap: "anywhere" };
619
+
620
+ /**
621
+ * Hover card body for the input chip — INPUT CHIP ONLY. Pure presentation
622
+ * over fields the status response already carries, plus the `detail=1`
623
+ * additions (recent commits, stash) when they have arrived. Everything is
624
+ * spelled out in text, so no fact here depends on a colour or a glyph.
625
+ */
626
+ function HoverCard({ info, detail }) {
627
+ // the detail payload is a superset of the plain one, so it wins when it
628
+ // has landed; until then the card still shows the base facts
629
+ const data = detail === void 0 ? info : detail;
630
+ const rows = [];
631
+ const add = (label, value) => {
632
+ if (value === void 0 || value === null || value === "") return;
633
+ rows.push(
634
+ react_jsx_runtime.jsxs("div", {
635
+ style: CARD_ROW,
636
+ children: [
637
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: label }),
638
+ react_jsx_runtime.jsx("span", { style: CARD_VALUE, children: value })
639
+ ]
640
+ }, label)
641
+ );
642
+ };
643
+ add("branch", info.branch);
644
+ add("upstream", info.upstream === void 0 ? "none configured" : info.upstream);
645
+ if (info.ahead !== void 0 || info.behind !== void 0) {
646
+ add("sync", "\u2191" + (info.ahead || 0) + " \u2193" + (info.behind || 0));
647
+ }
648
+ add("files", formatFileBreakdown(info));
649
+ add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
650
+ add("pull request", formatPrDetail(data.pr));
651
+ // WHICH checkout the badge describes, when it is a worktree. The chip shows
652
+ // the branch alone, so this row is the only place the tree is named.
653
+ add("worktree", worktreeDetail(data));
654
+ // The conversation's OWN directory, present only when the badge is
655
+ // describing an inferred worktree — the chip's branch and counts are the
656
+ // tree's in that case, so this is where the checkout's own state stays
657
+ // visible. Absent until the `detail=1` response lands, like commits/stash.
658
+ if (data.worktreeInferred === true) add("checkout", formatCheckoutDetail(data.checkout));
659
+ if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
660
+ rows.push(
661
+ react_jsx_runtime.jsxs("div", {
662
+ style: CARD_ROW,
663
+ children: [
664
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: "commits" }),
665
+ react_jsx_runtime.jsx("span", {
666
+ style: { ...CARD_VALUE, display: "flex", flexDirection: "column", gap: "2px" },
667
+ children: data.lastCommits.map((commit) =>
668
+ react_jsx_runtime.jsxs("span", {
669
+ children: [
670
+ react_jsx_runtime.jsx("span", { style: CARD_LABEL, children: commit.hash }),
671
+ " " + commit.subject + (commit.when === "" ? "" : " \u00B7 " + commit.when)
672
+ ]
673
+ }, commit.hash)
674
+ )
675
+ })
676
+ ]
677
+ }, "commits")
678
+ );
679
+ }
680
+ add("stash", data.stashCount === void 0 ? void 0 : data.stashCount + " stashed");
681
+ return react_jsx_runtime.jsx("div", { style: CARD_CONTAINER, children: rows });
391
682
  }
392
683
 
393
684
  //#region composer chip (upstream additive surface: conversation.input.left)
@@ -399,33 +690,103 @@ window.__ModuleLoader__.load({
399
690
  * (conversation.input.left is an upstream additive slot), and stays useful
400
691
  * next to the sidebar rows because it is context-anchored ("where am I")
401
692
  * rather than surveying.
693
+ *
694
+ * This is the ONLY surface that asks the node half for the extras (`pr=1`
695
+ * always, `detail=1` on hover), because it is the only one that renders
696
+ * them — see targetQuery.
402
697
  */
403
698
  function ComposerGitChip({ sessionId }) {
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 });
699
+ const target = sessionId === void 0 ? void 0 : { kind: "session", id: sessionId };
700
+ // the PR/CI token is always on the chip, so its fetch is not gated
701
+ const info = useGitStatus(target, { pr: true });
702
+ // The card's extras cost a `log -3` plus a stash list, so they are
703
+ // fetched only once a pointer actually RESTS on the chip, then kept
704
+ // fresh by the same SSE path. Eagerly asking would add both invocations
705
+ // to every refresh — and a refresh fires on every file edit.
706
+ const [hovered, setHovered] = react.useState(false);
707
+ // the PR token's own hover/focus state: it underlines on hover (and on
708
+ // focus) rather than at rest, and that underline is applied inline below
709
+ const [linkHover, setLinkHover] = react.useState(false);
710
+ const detail = useGitStatus(target, { pr: true, detail: true, enabled: hovered });
407
711
  if (info === void 0 || info.git !== true) return null;
408
712
  // the mark is an element now rather than a leading glyph in the string, so
409
713
  // the SAME StatusMark the sidebar row draws carries the status here too;
410
714
  // the container's 4px gap supplies the space the emoji's own did
411
- const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
412
- return react_jsx_runtime.jsx("span", {
715
+ const text = info.branch + formatOperationToken(info) + formatGitSuffix(info);
716
+ const prToken = formatPrToken(info);
717
+ const prUrl = prLinkUrl(info);
718
+ const chip = react_jsx_runtime.jsxs("span", {
413
719
  style: {
414
720
  display: "inline-flex",
415
721
  alignItems: "center",
416
722
  flex: "none",
417
- gap: "4px",
723
+ // 6px, not 4px: the PR token leads with a space, and CSS drops
724
+ // leading whitespace at the start of a flex item, so 4px read as no
725
+ // separation at all between the branch and `PR#…`
726
+ gap: "6px",
418
727
  color: "var(--dsw-alias-label-secondary, #5b6570)",
419
728
  fontSize: "12px",
420
729
  lineHeight: "24px",
421
730
  whiteSpace: "nowrap",
422
731
  cursor: "default"
423
732
  },
733
+ // the card is fetched for a pointer that RESTS here, not one that
734
+ // merely crosses the chip
735
+ onPointerEnter: () => setHovered(true),
424
736
  children: [
425
737
  react_jsx_runtime.jsx(StatusMark, { key: "mark", info }),
426
- react_jsx_runtime.jsx("span", { key: "text", children: text })
738
+ react_jsx_runtime.jsx("span", { key: "text", children: text }),
739
+ prToken === ""
740
+ ? null
741
+ : prUrl === void 0
742
+ ? react_jsx_runtime.jsx("span", {
743
+ key: "pr",
744
+ // the glyph is not the only channel: the token says what the
745
+ // CI state IS, for anyone who cannot see it
746
+ "aria-label": prTokenLabel(info),
747
+ children: prToken
748
+ })
749
+ : react_jsx_runtime.jsx("a", {
750
+ key: "pr",
751
+ href: prUrl,
752
+ // a new tab, because navigating THIS one away from the app
753
+ // would lose the conversation; noopener/noreferrer keep the
754
+ // opened tab from getting a handle on it
755
+ target: "_blank",
756
+ rel: "noopener noreferrer",
757
+ "aria-label": prTokenLabel(info) + ", opens on GitHub in a new tab",
758
+ // the chip's own colour rather than the browser's link blue /
759
+ // visited purple, so the token still reads as one row. The
760
+ // affordance is the pointer cursor plus an underline on hover
761
+ // or focus, set HERE rather than left to the host stylesheet:
762
+ // a shell that resets anchors would otherwise drop it silently.
763
+ style: {
764
+ color: "inherit",
765
+ textDecoration: linkHover ? "underline" : "none",
766
+ cursor: "pointer"
767
+ },
768
+ // focus joins hover — the token is keyboard-reachable now, and a
769
+ // keyboard user needs the same "this is a link" signal
770
+ onPointerEnter: () => setLinkHover(true),
771
+ onPointerLeave: () => setLinkHover(false),
772
+ onFocus: () => setLinkHover(true),
773
+ onBlur: () => setLinkHover(false),
774
+ children: prToken
775
+ })
427
776
  ]
428
777
  });
778
+ // No Tooltip primitive (a shell that does not seed it): render the chip
779
+ // itself rather than losing the badge. The card is an enhancement, the
780
+ // badge is the feature.
781
+ if (Tooltip === void 0) return chip;
782
+ return react_jsx_runtime.jsx(Tooltip, {
783
+ side: "top",
784
+ maxWidth: 360,
785
+ // a function label keeps the card's element tree out of every render
786
+ // until the tooltip actually opens
787
+ label: () => react_jsx_runtime.jsx(HoverCard, { info, detail }),
788
+ children: chip
789
+ });
429
790
  }
430
791
  //#endregion
431
792
 
@@ -435,15 +796,15 @@ window.__ModuleLoader__.load({
435
796
  const inject = ["slots"];
436
797
 
437
798
  /**
438
- * Register the badge into both seams. The seam owner hands each entry the
439
- * row owner share as props; the badge destructures { workspaceId, label }
440
- * and deliberately ignores the cwd it is also given.
799
+ * Register the badge into the seams. The seam owner hands each entry the row
800
+ * owner share as props; the row badge destructures `{ sessionId }` and
801
+ * deliberately ignores the cwd it may also be given.
441
802
  */
442
803
  function apply(ctx) {
443
804
  // inject() re-evaluates when a seam's declaration appears, so boot
444
805
  // order relative to the workspace browser does not matter. On an
445
- // unpatched install the row seam is never declared, so this callback
446
- // never fires and the row badge simply never renders.
806
+ // unpatched install the seam is never declared, so this callback never
807
+ // fires and the row badge simply never renders.
447
808
  //
448
809
  // The registration is reported from INSIDE the callback on purpose: a
449
810
  // `spec()` check here at apply() time runs before the workspace browser
@@ -451,16 +812,24 @@ window.__ModuleLoader__.load({
451
812
  // outcome. That mistake made the old one-shot line claim "sidebar rows
452
813
  // = off" while five row badges were rendering.
453
814
  let rowsReported = false;
454
- ctx.slots.inject("sidebar.workspaces.row", () => {
815
+ ctx.slots.inject("sidebar.workspaces.sessionRow", () => {
455
816
  if (!rowsReported) {
456
817
  rowsReported = true;
457
- console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
818
+ console.info("[dsh-git-badge] surfaces: sidebar session rows = on (seam present).");
458
819
  }
459
820
  return ctx.slots.register({
460
- name: "sidebar.workspaces.row",
461
- id: "git-badge"
462
- }, WorkspaceGitBadge);
821
+ name: "sidebar.workspaces.sessionRow",
822
+ id: "git-badge-row"
823
+ }, SessionGitBadge);
463
824
  });
825
+ // The row is ALREADY a HoverCard anchor upstream, so a badge that opened
826
+ // its own tooltip would nest two cards. This additive slot renders the
827
+ // provenance line inside that card instead — the same pattern the
828
+ // workspace-row detail slot used. It issues no request of its own.
829
+ ctx.slots.inject("sidebar.workspaces.sessionRow.detail", () => ctx.slots.register({
830
+ name: "sidebar.workspaces.sessionRow.detail",
831
+ id: "git-badge-row-detail"
832
+ }, SessionGitDetail));
464
833
  // Input-row chip: upstream additive slot rendered in the input bar's
465
834
  // leading cluster, right after the access picker — the git state sits
466
835
  // with the controls that govern the conversation. Present on every
@@ -472,12 +841,35 @@ window.__ModuleLoader__.load({
472
841
  id: "git-badge-chip",
473
842
  inject: (sessionId) => ({ sessionId })
474
843
  }, ComposerGitChip));
475
- const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
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."));
844
+ const seamDeclared = ctx.slots.spec("sidebar.workspaces.sessionRow") !== void 0;
845
+ console.info(
846
+ "[dsh-git-badge] surfaces: input chip = on; sidebar session rows = " +
847
+ (seamDeclared
848
+ ? "on (seam present)."
849
+ : // discoverability: the chip works everywhere, but the session rows
850
+ // need the seam patch, and on a market install nothing else says so.
851
+ // The patcher ships IN this package — no clone needed.
852
+ "off (seam absent) — run `npx dsh-git-badge-seam apply` to add them, then restart dsh web.")
853
+ );
854
+ // The hover card depends on a primitive the SHELL seeds, not on anything
855
+ // this plugin declares. Report the outcome rather than letting a missing
856
+ // seed look like a missing feature: the try/catch above deliberately keeps
857
+ // the badge working, which would otherwise make this failure silent.
858
+ console.info(
859
+ "[dsh-git-badge] hover card = " +
860
+ (Tooltip === void 0
861
+ ? "off (this shell seeds no Tooltip primitive) — the chip renders uncarded."
862
+ : "on (shell Tooltip primitive).")
863
+ );
477
864
  }
478
865
 
479
866
  exports.apply = apply;
480
867
  exports.inject = inject;
868
+ // ---------- test-only exports ----------
869
+ // Additive; the host reads apply/inject and ignores the rest. The suite
870
+ // drives these to assert the REQUEST contract — which surface asks for the
871
+ // expensive extras — without a browser, a fetch or a network.
872
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken };
481
873
  return module.exports;
482
874
  }
483
875
  });