dsh-git-badge 0.8.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/CHANGELOG.md CHANGED
@@ -8,7 +8,95 @@ this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm
8
8
  Versions before 0.6.0 predate this file; their history is in `git log` and on
9
9
  npm. 0.6.0 is the first release recorded here.
10
10
 
11
- ## [Unreleased]
11
+ ## [0.9.0] - 2026-09-14
12
+
13
+ ### Added
14
+
15
+ - **The seam patcher ships in the package** as the `dsh-git-badge-seam` bin, so a
16
+ market install can add the sidebar session-row badges without cloning the
17
+ repository: `npx dsh-git-badge-seam apply`, then restart. Pure Node (no
18
+ python3), anchor-based like the repo tooling it replaces, with the same
19
+ refusal semantics: it writes nothing unless every anchor resolves exactly
20
+ once, upgrades its own older artifacts by marker revision, and reverts to the
21
+ bytes as found before patching. When the seam is absent the plugin's boot log
22
+ now says so and names the command.
23
+
24
+ ### Added
25
+
26
+ - **A session's badge follows the worktree its work is in.** A conversation's
27
+ directory is fixed when it is created, and DSH records no session→worktree link
28
+ at all (`attachSession` requires the stored cwd to equal the workspace path), so
29
+ a conversation started in the main checkout reported `main` — including "no pull
30
+ request" — while its work and its PR lived in a linked worktree.
31
+ Now, when a **session**-targeted badge resolves to a main checkout whose
32
+ repository has exactly one linked worktree whose branch has an **open** pull
33
+ request, the badge describes that worktree instead: its branch, dirty counts,
34
+ ahead/behind, tree-shaped mark, and PR/CI token. The `workspace` id in the
35
+ response is unchanged, so SSE attribution is unaffected.
36
+ The selection is deliberately the only one that cannot be ambiguous — one open
37
+ PR is a candidate, two are a reason to say nothing — the worktree is **named**
38
+ on the chip even when the branch would make the name look redundant, the mark's
39
+ accessible name states that the checkout was inferred, and the hover card gains
40
+ a `checkout` row carrying the conversation's OWN directory (branch · files ·
41
+ sync) so the main checkout's state stays visible. A workspace-targeted sidebar
42
+ row never follows a worktree: a row surveys the checkout the registry owns.
43
+ - **An inferred worktree is watched.** Its working tree and git dir get the same
44
+ event-driven treatment as a registered workspace's, and its events carry the
45
+ **owning workspace id**, so the badge refreshes within ~1s of a stage, commit or
46
+ checkout in the tree — including a worktree that is not itself a registered
47
+ workspace, which the main checkout's recursive watch would never see.
48
+ - **Identical concurrent status reads are collapsed** into one `git status` per
49
+ directory, which is the shape a burst of badges mounting together produces.
50
+ `statusCacheMs` (default 0) optionally extends that to serial bursts; the 0
51
+ default keeps a read taken after a mutation honest. New config: `worktreeStatus`
52
+ (`"auto"` | `"off"`), `prListLimit`, `statusCacheMs`.
53
+ - The repository-wide open-PR read (`gh pr list`) happens at most once per TTL per
54
+ repository, and never at all for a repository with no linked worktree.
55
+
56
+ ### Changed
57
+
58
+ - **The sidebar session row is an ACTION token, not a status badge.** It shows
59
+ the one thing that conversation needs — `merge`, `fix CI`, `review`, `resolve`,
60
+ `pull`, `push` — and nothing when there is nothing to do. `merge` keys on
61
+ GitHub's own `mergeStateStatus: CLEAN` rather than a verdict assembled here; a
62
+ draft, blocked, behind, unstable or unknown PR stays silent, because waiting is
63
+ not an action. This is deliberately a trade: the row no longer shows local
64
+ status or the worktree's tree shape, and the hover line — which now names the
65
+ pull request as well as the checkout — is where that detail lives. The word floats right so a column of actions lines
66
+ up down the sidebar, sits heavier than the timestamp beside it, and is coloured
67
+ by SEVERITY with the app's own state tokens — error for `resolve` and `fix CI`,
68
+ warn for `review`, success for `merge`, quiet secondary for `pull` and `push` —
69
+ so the loud rows keep meaning something. The chip is unchanged. Revert this
70
+ commit to go back to the mark-plus-token row.
71
+ - **The sidebar badge moved from the workspace row to the session row.** A
72
+ workspace row cannot know which worktree its conversations are using, so a
73
+ per-workspace badge could only guess or report the main checkout. Each session
74
+ row now carries the status mark plus the PR/CI token when the branch has one
75
+ (`🌳 PR#391 ✓`), and never a branch — hovering the row adds a line naming the
76
+ checkout that badge describes (`checkout: hotfix-tree on feat/x`, and for an
77
+ inferred worktree, why it was followed). Rows still issue no request of their
78
+ own beyond the badge's: the hover line resolves to the same cache key, so a card
79
+ that mounts for every row cannot multiply git or forge work.
80
+ - The `seam/` patch now declares `sidebar.workspaces.sessionRow` and
81
+ `sidebar.workspaces.sessionRow.detail` instead of the old
82
+ `sidebar.workspaces.row` pair, and the row is rendered from the **tree** call
83
+ site only, which keeps the flat and search lists badge-free with no guard of
84
+ their own. `seam/apply.sh` recognises its own older artifacts by a marker
85
+ comment (and the previous hash), so rebuilding the patch installs it instead of
86
+ being skipped as "upstream landed" — and `revert` now restores the upstream
87
+ baseline rather than an older patch.
88
+
89
+ ### Fixed
90
+
91
+ - **A failing seam occupant could blank the whole sidebar.** The host guards each
92
+ registered *entry* with an error boundary, but not the outlet a row renders
93
+ itself, so a throw in an occupant's render path reached the workspace browser —
94
+ and the shell abdicates that entry, removing the entire sidebar region about a
95
+ second after boot. The seam patch now wraps both session-row renders in its own
96
+ boundary: a broken occupant loses its badge and logs the error instead.
97
+ - **The PR cache served the wrong branch.** `prStatusFor` was keyed by toplevel
98
+ only, so a checkout that changed branch inside the TTL window was served the
99
+ previous branch's pull request. It is now keyed by toplevel **and** branch.
12
100
 
13
101
  ## [0.8.0] - 2026-09-12
14
102
 
package/README.md CHANGED
@@ -5,11 +5,17 @@ Git status badges for [DeepSeek Harness](https://github.com/deepseek-ai/deepseek
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
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.
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
- - **Sidebar row badges** — the same status mark floated to the right of each workspace row (`Project 1 ●`), with a linked worktree's name beside it (`Project 2 hotfix-tree 🌳`). Requires the `sidebar.workspaces.row` seam; without it the plugin degrades to chip-only and logs what's active to the browser console. The hover card and PR token are chip-only, so they never need the seam.
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. When exactly one linked worktree of that repository has an open pull request, 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, says it was inferred, and adds a `checkout` row with the conversation's own directory, so the main checkout's branch and dirty state stay one hover away. One candidate only — two open PRs across worktrees is a reason to say nothing rather than guess — and a session row always reports the checkout that session is working in.
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. Requires the `sidebar.workspaces.sessionRow` seam; without it the plugin degrades to chip-only and logs what's active to the browser console.
10
+ - **Nothing on workspace rows.** A workspace cannot know which worktree its conversations are using, so a per-workspace badge could only guess or report the main checkout. The seam is on the session row for that reason.
9
11
 
10
- The leading **mark** is identical on both surfaces: a circle filled with the status colour, or a **tree** when the workspace is a linked `git worktree`. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
12
+ The leading **mark** belongs to the input chip: a circle filled with the status colour, or a **tree** when the checkout is a linked `git worktree`. The sidebar row deliberately carries no mark — see the row bullet above, where one word does the job a colour cannot. (`●`/`🌳` stand in for it here — it is a drawn SVG so that it can take the theme's status colours.)
11
13
 
12
- Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit. One `git status --porcelain=v2` call per sample (plus optionally one `gh` call per ~90s, chip only). The status API takes **no path**: the caller passes a session id (chip) or workspace id (row) and the server resolves the directory itself.
14
+ Event-driven freshness: working trees are watched and updates push over SSE — badges flip within ~1s of any commit, stage, or edit, including inside a linked worktree that is only inferred. One `git status --porcelain=v2` per sample, shared by every surface pointing at the same checkout, plus at most one `gh` read per repository-and-branch per ~90s and one repository-wide open-PR list per ~90s. The status API takes **no path**: the caller passes a session id (chip and sidebar rows) or a workspace id, and the server resolves the directory itself.
15
+
16
+ Diagrams, the hover card's full field list and a walkthrough for git newcomers live
17
+ in the [repo README](https://github.com/Kevin-McIsaac/dsh-workspace-git-badge) —
18
+ this file is the npm-facing summary.
13
19
 
14
20
  ## Install
15
21
 
package/lib/client.js CHANGED
@@ -6,19 +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
17
  * worktree's name + in-progress operation token + sync/dirty suffix + the
14
18
  * PR/CI token, with a HOVER CARD carrying the breakdown the chip has no room
15
- * for (file detail, recent commits, stash, PR state).
19
+ * for (file detail, recent commits, stash, PR state, and the conversation's own
20
+ * checkout when the badge followed a worktree).
16
21
  *
17
- * The hover card is the INPUT CHIP's, not the sidebar row's, and it needs
18
- * nothing but the upstream slot: the Tooltip primitive is seeded by the shell
19
- * itself, so the card works on a PRISTINE install with no seam patch. Its extra
20
- * fields are fetched only once a pointer rests on the chip, so the everyday
21
- * badge pays for none of them.
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.
22
26
  */
23
27
  window.__ModuleLoader__.load({
24
28
  id: "dsh-git-badge",
@@ -340,43 +344,12 @@ window.__ModuleLoader__.load({
340
344
  }
341
345
 
342
346
  /**
343
- * Linked-worktree NAME token — INPUT CHIP ONLY, and only the name: worktree-
344
- * ness itself is carried by the status mark's shape (a tree instead of a
345
- * circle) on both surfaces. The chip has no other workspace identity, so
346
- * several worktrees of one repository would otherwise render identical chips
347
- * with no way to tell which checkout a conversation is in. The node half
348
- * reports whether this is a linked worktree plus its directory NAME (never a
349
- * path).
350
- *
351
- * The name is appended only when it says something the branch does not: the
352
- * usual `repo-feat-x` directory sitting on branch `feat-x` would otherwise
353
- * read as stutter. (The ROW needs no such rule — it shows the name instead
354
- * of the branch, so there is nothing to stutter against.)
355
- */
356
-
357
- /**
358
- * True when the worktree name is already implied by the branch name, so
359
- * showing both is pure repetition. Compared on a normalized form —
360
- * case-folded, with `/` and `_` folded to `-` — because directory and
361
- * branch conventions differ without changing the meaning (`feat/x` in a
362
- * `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.)
363
352
  */
364
- function worktreeNameIsRedundant(name, branch) {
365
- if (typeof name !== "string" || name === "" || typeof branch !== "string") return true;
366
- const normalize = (value) => value.toLowerCase().replace(/[/_]/g, "-");
367
- const normalizedName = normalize(name);
368
- const normalizedBranch = normalize(branch);
369
- return normalizedName === normalizedBranch
370
- || normalizedName.endsWith("-" + normalizedBranch)
371
- || normalizedBranch.endsWith("-" + normalizedName);
372
- }
373
-
374
- /** ` name` for a worktree whose name the branch does not already say, else "". */
375
- function formatWorktreeToken(info) {
376
- if (info.isWorktree !== true) return "";
377
- const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
378
- return worktreeNameIsRedundant(name, info.branch) ? "" : " " + name;
379
- }
380
353
 
381
354
  const META_STYLE = {
382
355
  color: "var(--dsw-alias-label-tertiary, #9ea7ad)",
@@ -412,7 +385,12 @@ window.__ModuleLoader__.load({
412
385
  height: 14,
413
386
  viewBox: "0 0 12 12",
414
387
  role: "img",
415
- "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)" : ""),
416
394
  focusable: "false",
417
395
  style: { flex: "none", display: "block", color: META_STYLE.color }
418
396
  };
@@ -436,52 +414,135 @@ window.__ModuleLoader__.load({
436
414
  }
437
415
 
438
416
  /**
439
- * A linked worktree's directory name, or null when there is nothing to
440
- * show. A main checkout shows NO text: the row surveys status and identity,
441
- * and the branch lives on the input chip — which is why the branch is never
442
- * rendered here. The name is the one thing the row's own label (the
443
- * worktree directory by default) cannot be trusted to say once a workspace
444
- * 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.
445
430
  */
446
- function rowName(info) {
447
- if (info.isWorktree !== true) return null;
448
- const name = typeof info.worktreeName === "string" ? info.worktreeName : "";
449
- 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
+ });
450
510
  }
451
511
 
452
512
  /**
453
- * Row badge — workspace name on the left, the status mark floated right:
454
- * the_paragliding_app ●
455
- * worktree_thing hotfix-tree 🌳
456
- * Fill colour via badgeStatus(): green clean+synced, amber dirty or
457
- * out-of-sync, red conflict or dirty-and-behind. Shape via the node half's
458
- * isWorktree: circle = main checkout, tree = linked worktree.
459
- * Always renders the workspace name (so the row keeps its identity) and
460
- * appends the right-hand mark only for git workspaces. No branch, and no
461
- * `|` 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.
462
517
  *
463
- * Targets the row's `workspaceId`. The seam also passes `cwd`, which is
464
- * deliberately ignored: the node half resolves the directory itself, so
465
- * the client never has to name one. A row with no workspaceId (the
466
- * ungrouped bucket) has no workspace to report on, so it renders
467
- * name-only.
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`.
526
+ *
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.
468
530
  */
469
- function WorkspaceGitBadge({ label, workspaceId }) {
470
- // no detail=1: the row needs status and identity only, and the extra
471
- // log / stash calls have no consumer yet
472
- const info = useGitStatus(workspaceId === void 0 ? void 0 : { kind: "workspace", id: workspaceId });
473
- const children = [react_jsx_runtime.jsx("span", { style: { minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: label })];
474
- if (workspaceId !== void 0 && info !== void 0 && info.git === true) {
475
- // marginLeft:auto floats the mark to the right edge, so the status
476
- // shape holds a fixed right-hand column and a worktree's name grows
477
- // leftwards from it instead of shoving the shape around.
478
- const meta = [];
479
- const name = rowName(info);
480
- if (name !== null) meta.push(react_jsx_runtime.jsx("span", { key: "name", style: META_STYLE, children: name }));
481
- meta.push(react_jsx_runtime.jsx(StatusMark, { key: "mark", info }));
482
- children.push(react_jsx_runtime.jsx("span", { style: { display: "inline-flex", alignItems: "center", gap: "6px", flex: "none", marginLeft: "auto", paddingLeft: "8px" }, children: meta }));
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 "));
483
542
  }
484
- return react_jsx_runtime.jsx("span", { style: { display: "flex", alignItems: "center", minWidth: 0, width: "100%" }, children });
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 ") });
485
546
  }
486
547
 
487
548
  /**
@@ -512,6 +573,38 @@ window.__ModuleLoader__.load({
512
573
  return bits.join(" \u00B7 ");
513
574
  }
514
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.
582
+ */
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));
589
+ }
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
+
515
608
  const CARD_CONTAINER = {
516
609
  display: "flex",
517
610
  flexDirection: "column",
@@ -555,6 +648,14 @@ window.__ModuleLoader__.load({
555
648
  add("files", formatFileBreakdown(info));
556
649
  add("operation", info.operation === void 0 || info.operation === null ? void 0 : String(info.operation));
557
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));
558
659
  if (Array.isArray(data.lastCommits) && data.lastCommits.length > 0) {
559
660
  rows.push(
560
661
  react_jsx_runtime.jsxs("div", {
@@ -611,7 +712,7 @@ window.__ModuleLoader__.load({
611
712
  // the mark is an element now rather than a leading glyph in the string, so
612
713
  // the SAME StatusMark the sidebar row draws carries the status here too;
613
714
  // the container's 4px gap supplies the space the emoji's own did
614
- const text = info.branch + formatWorktreeToken(info) + formatOperationToken(info) + formatGitSuffix(info);
715
+ const text = info.branch + formatOperationToken(info) + formatGitSuffix(info);
615
716
  const prToken = formatPrToken(info);
616
717
  const prUrl = prLinkUrl(info);
617
718
  const chip = react_jsx_runtime.jsxs("span", {
@@ -619,7 +720,10 @@ window.__ModuleLoader__.load({
619
720
  display: "inline-flex",
620
721
  alignItems: "center",
621
722
  flex: "none",
622
- 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",
623
727
  color: "var(--dsw-alias-label-secondary, #5b6570)",
624
728
  fontSize: "12px",
625
729
  lineHeight: "24px",
@@ -692,15 +796,15 @@ window.__ModuleLoader__.load({
692
796
  const inject = ["slots"];
693
797
 
694
798
  /**
695
- * Register the badge into both seams. The seam owner hands each entry the
696
- * row owner share as props; the badge destructures { workspaceId, label }
697
- * 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.
698
802
  */
699
803
  function apply(ctx) {
700
804
  // inject() re-evaluates when a seam's declaration appears, so boot
701
805
  // order relative to the workspace browser does not matter. On an
702
- // unpatched install the row seam is never declared, so this callback
703
- // 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.
704
808
  //
705
809
  // The registration is reported from INSIDE the callback on purpose: a
706
810
  // `spec()` check here at apply() time runs before the workspace browser
@@ -708,16 +812,24 @@ window.__ModuleLoader__.load({
708
812
  // outcome. That mistake made the old one-shot line claim "sidebar rows
709
813
  // = off" while five row badges were rendering.
710
814
  let rowsReported = false;
711
- ctx.slots.inject("sidebar.workspaces.row", () => {
815
+ ctx.slots.inject("sidebar.workspaces.sessionRow", () => {
712
816
  if (!rowsReported) {
713
817
  rowsReported = true;
714
- console.info("[dsh-git-badge] surfaces: sidebar rows = on (seam present).");
818
+ console.info("[dsh-git-badge] surfaces: sidebar session rows = on (seam present).");
715
819
  }
716
820
  return ctx.slots.register({
717
- name: "sidebar.workspaces.row",
718
- id: "git-badge"
719
- }, WorkspaceGitBadge);
821
+ name: "sidebar.workspaces.sessionRow",
822
+ id: "git-badge-row"
823
+ }, SessionGitBadge);
720
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));
721
833
  // Input-row chip: upstream additive slot rendered in the input bar's
722
834
  // leading cluster, right after the access picker — the git state sits
723
835
  // with the controls that govern the conversation. Present on every
@@ -729,8 +841,16 @@ window.__ModuleLoader__.load({
729
841
  id: "git-badge-chip",
730
842
  inject: (sessionId) => ({ sessionId })
731
843
  }, ComposerGitChip));
732
- const seamDeclared = ctx.slots.spec("sidebar.workspaces.row") !== void 0;
733
- 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
+ );
734
854
  // The hover card depends on a primitive the SHELL seeds, not on anything
735
855
  // this plugin declares. Report the outcome rather than letting a missing
736
856
  // seed look like a missing feature: the try/catch above deliberately keeps
@@ -749,7 +869,7 @@ window.__ModuleLoader__.load({
749
869
  // Additive; the host reads apply/inject and ignores the rest. The suite
750
870
  // drives these to assert the REQUEST contract — which surface asks for the
751
871
  // expensive extras — without a browser, a fetch or a network.
752
- exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail };
872
+ exports.__internals = { targetQuery, formatPrToken, formatFileBreakdown, formatPrDetail, formatCheckoutDetail, worktreeDetail, actionToken };
753
873
  return module.exports;
754
874
  }
755
875
  });