dsh-graft-plugin 0.2.1 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/client.js CHANGED
@@ -8,6 +8,21 @@ window.__ModuleLoader__.load({
8
8
  const React = require("react");
9
9
  const h = React.createElement;
10
10
 
11
+ /**
12
+ * dsh's own themed modal chrome, the same package permission-presets
13
+ * renders its RiskConfirmation from — body-portaled, Escape + mask-click
14
+ * handled, themed through the --dsw-* variables for free.
15
+ *
16
+ * Guarded: if the package is ever unresolvable the confirm falls back to
17
+ * window.confirm, so the gate can never go missing silently.
18
+ */
19
+ let Primitives = null;
20
+ try {
21
+ Primitives = require("@deepseek-ai/dsh-client-ui-primitives");
22
+ } catch {
23
+ Primitives = null;
24
+ }
25
+
11
26
  /* ================================================================== *
12
27
  * graft-status — the browser half.
13
28
  *
@@ -55,8 +70,10 @@ window.__ModuleLoader__.load({
55
70
  * validator requires a strict codec to expose a `create()` factory
56
71
  * returning the schema (it throws "strict codec has no create() factory"
57
72
  * otherwise, failing `$mount` and with it the whole plugin), while dsh 0.1
58
- * read `schema` directly. The host still runs assertJsonValue on every
59
- * decoded value, so identity is safe.
73
+ * read `schema` directly; dsh 0.1.7-rc.2 already refused a codec with no
74
+ * factory on this fork. `create()` returns the same schema object every
75
+ * time. The host still runs assertJsonValue on every decoded value, so
76
+ * identity is safe.
60
77
  */
61
78
  const IDENTITY_SCHEMA = { parse: identity };
62
79
  const CODEC = {
@@ -77,7 +94,7 @@ window.__ModuleLoader__.load({
77
94
  });
78
95
  const CONTRIBUTION = {
79
96
  package: "graft-status",
80
- descriptors: [descriptor("status", ["sessionId"]), descriptor("viz", ["sessionId"])],
97
+ descriptors: [descriptor("status", ["sessionId"]), descriptor("viz", ["sessionId"]), descriptor("build", ["sessionId"])],
81
98
  };
82
99
 
83
100
  /**
@@ -134,84 +151,114 @@ window.__ModuleLoader__.load({
134
151
  return [
135
152
  ".gs-wrap{display:inline-flex;align-items:center;gap:4px}",
136
153
  ".gs-chip{display:inline-flex;align-items:center;gap:5px;height:26px;padding:0 9px;",
137
- "border-radius:13px;border:1px solid var(--dsh-border,rgba(128,128,128,.28));",
138
- "background:var(--dsh-surface-2,rgba(128,128,128,.08));color:var(--dsh-text-2,inherit);",
154
+ "border-radius:13px;border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.28));",
155
+ "background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));color:var(--dsw-alias-label-secondary,inherit);",
139
156
  "font-size:12px;line-height:1;white-space:nowrap;cursor:default;max-width:200px}",
140
157
  ".gs-chip .gs-name{font-weight:600;overflow:hidden;text-overflow:ellipsis}",
158
+ // The repo name in the trailing-row chip. The chip caps at 200px but
159
+ // the name itself had no ellipsis of its own, so a long workspace name
160
+ // stretched the composer instead of collapsing to "name…". The brand
161
+ // ("graft") and separators never shrink; this span takes the cut.
162
+ ".gs-chip .gs-label{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0;flex:0 1 auto}",
141
163
  ".gs-chip .gs-sep{opacity:.45}",
142
164
  ".gs-chip .gs-dot{font-size:9px;line-height:1}",
143
- ".gs-ok .gs-dot{color:#3fa45b}",
144
- ".gs-warn .gs-dot{color:#d08442}",
145
- ".gs-busy .gs-dot{color:#4b8bd6;display:inline-block;animation:gs-spin 900ms linear infinite}",
165
+ ".gs-ok .gs-dot{color:var(--dsw-alias-state-success-primary,#3fa45b)}",
166
+ ".gs-warn .gs-dot{color:var(--dsw-alias-state-warn-primary,#d08442)}",
167
+ ".gs-busy .gs-dot{color:var(--dsw-alias-state-business-primary,#4b8bd6);display:inline-block;animation:gs-spin 900ms linear infinite}",
146
168
  "@keyframes gs-spin{to{transform:rotate(360deg)}}",
147
- /* A rebuild takes about a second, so the chip also breathes: the dot
148
- alone is small enough to miss at a glance. */
149
169
  ".gs-chip.gs-busy{animation:gs-breathe 1.4s ease-in-out infinite}",
150
170
  "@keyframes gs-breathe{0%,100%{opacity:1}50%{opacity:.62}}",
151
171
  "@media (prefers-reduced-motion:reduce){.gs-busy .gs-dot{animation:none}.gs-chip.gs-busy{animation:none}}",
152
- ".gs-idle .gs-dot{color:var(--dsh-text-3,#888)}",
172
+ ".gs-idle .gs-dot{color:var(--dsw-alias-label-tertiary,#888)}",
173
+ // A graft tool is running: the dot blinks violet, on and off. The
174
+ // blinking itself is JS toggling `gs-live`; this only paints it.
175
+ // Violet is pinned, not an alias: dsw's state palette has no violet,
176
+ // and it must NOT borrow the busy blue — the two states would be
177
+ // indistinguishable. Against both green and orange it also pops.
178
+ ".gs-chip.gs-live .gs-dot{color:#8b5cf6}",
153
179
  ".gs-chip.gs-muted{opacity:.6}",
154
180
  ".gs-chip.gs-clickable{cursor:pointer}",
155
- ".gs-chip.gs-clickable:hover{border-color:var(--dsh-border-strong,rgba(128,128,128,.5))}",
156
- ".gs-chip.gs-clickable:focus-visible{outline:2px solid var(--dsh-accent,#4b8bd6);outline-offset:2px}",
181
+ ".gs-chip.gs-clickable:hover{border-color:var(--dsw-alias-border-l3,rgba(128,128,128,.5))}",
182
+ ".gs-chip.gs-clickable:focus-visible{outline:2px solid var(--dsw-focus-ring-color,var(--dsw-alias-brand-primary,#4b8bd6));outline-offset:2px}",
157
183
  ".gs-viz{display:inline-flex;align-items:center;justify-content:center;width:28px;height:28px;",
158
- "padding:0;border:0;border-radius:8px;background:transparent;color:var(--dsh-text-2,inherit);",
159
- "cursor:pointer;--accent:var(--dsh-accent,#e5484d)}",
160
- ".gs-viz:hover{background:var(--dsh-surface-2,rgba(128,128,128,.14))}",
184
+ "padding:0;border:0;border-radius:var(--dsw-radius-sm,8px);background:transparent;color:var(--dsw-alias-label-secondary,inherit);",
185
+ "cursor:pointer;--accent:var(--dsw-alias-brand-primary,#e5484d)}",
186
+ ".gs-viz:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))}",
161
187
  ".gs-viz:disabled{opacity:.5;cursor:progress}",
162
- ".gs-viz.gs-err{color:var(--dsh-danger,#e5484d)}",
163
- /* The tab: a header row in the sidebar's own idiom (short row, hairline
164
- rule, icon control on the right), then the visualiser filling the rest. */
188
+ ".gs-viz.gs-err{color:var(--dsw-alias-state-error-primary,#e5484d)}",
165
189
  ".gs-tabwrap{display:flex;flex-direction:column;height:100%;min-height:0}",
166
190
  ".gs-tabbar{display:flex;align-items:center;gap:6px;padding:0 6px 0 10px;min-height:32px;",
167
- "border-bottom:1px solid var(--dsh-border,rgba(128,128,128,.22));font-size:11.5px;",
168
- "white-space:nowrap;overflow:hidden;flex:0 0 auto}",
169
- ".gs-tb-brand{color:var(--dsh-accent,#e5484d);font-weight:700}",
191
+ "border-bottom:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.22));font-size:11.5px;",
192
+ "color:var(--dsw-alias-label-secondary,inherit);white-space:nowrap;overflow:hidden;flex:0 0 auto}",
193
+ ".gs-tb-brand{color:var(--dsw-alias-brand-primary,#e5484d);font-weight:700}",
170
194
  ".gs-tb-sep{opacity:.38}",
171
- ".gs-tb-dim{opacity:.72;overflow:hidden;text-overflow:ellipsis}",
195
+ ".gs-tb-dim{color:var(--dsw-alias-label-tertiary,currentColor);opacity:.72;overflow:hidden;text-overflow:ellipsis}",
172
196
  ".gs-tb-fresh{font-weight:600}",
173
- ".gs-tb-fresh.gs-ok{color:#5cbe78}",
174
- ".gs-tb-fresh.gs-warn{color:#e0a066}",
175
- ".gs-tb-fresh.gs-busy{color:#79aee8}",
176
- ".gs-tb-fresh.gs-idle{color:var(--dsh-text-3,#9a9a9a)}",
177
- ".gs-tb-saved{color:#79aee8}",
197
+ ".gs-tb-fresh.gs-ok{color:var(--dsw-alias-state-success-primary,#5cbe78)}",
198
+ ".gs-tb-fresh.gs-warn{color:var(--dsw-alias-state-warn-primary,#e0a066)}",
199
+ ".gs-tb-fresh.gs-busy{color:var(--dsw-alias-state-business-primary,#79aee8)}",
200
+ ".gs-tb-fresh.gs-idle{color:var(--dsw-alias-label-tertiary,#9a9a9a)}",
201
+ ".gs-tb-saved{color:var(--dsw-alias-link,#79aee8)}",
178
202
  ".gs-refresh{margin-left:auto;display:inline-flex;align-items:center;justify-content:center;",
179
- "width:24px;height:24px;flex:0 0 auto;padding:0;border:0;border-radius:6px;background:transparent;",
180
- "color:var(--dsh-text-2,inherit);cursor:pointer;opacity:.75}",
181
- ".gs-refresh:hover{background:var(--dsh-surface-2,rgba(128,128,128,.16));opacity:1}",
203
+ "width:24px;height:24px;flex:0 0 auto;padding:0;border:0;border-radius:var(--dsw-radius-sm,6px);background:transparent;",
204
+ "color:var(--dsw-alias-label-secondary,inherit);cursor:pointer;opacity:.75}",
205
+ ".gs-refresh:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.16));opacity:1}",
182
206
  ".gs-refresh.gs-spinning svg{animation:gs-spin 600ms linear}",
183
- ".gs-tabframe{width:100%;flex:1 1 auto;min-height:0;border:0;background:#fff;display:block}",
184
- ".gs-tabmsg{padding:14px;font-size:12px;line-height:1.6;opacity:.85}",
207
+ ".gs-tabframe{width:100%;flex:1 1 auto;min-height:0;border:0;background:var(--dsw-alias-bg-base,transparent);display:block}",
208
+ ".gs-tabmsg{padding:14px;font-size:12px;line-height:1.6;color:var(--dsw-alias-label-secondary,inherit);opacity:.85}",
185
209
  ".gs-tabreason{margin-top:6px;opacity:.7;font-family:ui-monospace,monospace;font-size:11px;word-break:break-word}",
186
- /* The hover card. Anchored to the wrap, which is why the wrap is
187
- positioned; `pointer-events:none` so it can never swallow a click
188
- meant for the button underneath it. */
189
210
  ".gs-wrap{position:relative}",
190
211
  ".gs-pop{position:absolute;bottom:calc(100% + 10px);right:0;z-index:80;width:296px;",
191
212
  "padding:12px 13px;border-radius:12px;pointer-events:none;text-align:left;",
192
- "border:1px solid var(--dsh-border,rgba(128,128,128,.3));",
193
- "background:var(--dsh-surface,#1b1b1e);color:var(--dsh-text,inherit);",
213
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));",
214
+ "background:var(--dsw-alias-bg-layer-2,#1b1b1e);color:var(--dsw-alias-label-primary,inherit);",
194
215
  "box-shadow:0 10px 30px rgba(0,0,0,.34);font-size:12px;line-height:1.5;",
195
216
  "opacity:0;transform:translateY(4px);animation:gs-pop-in .13s ease-out forwards}",
196
217
  "@keyframes gs-pop-in{to{opacity:1;transform:none}}",
197
218
  ".gs-pop-head{display:flex;align-items:center;gap:8px;margin-bottom:9px}",
198
219
  ".gs-pop-head .gs-pop-title{font-weight:700;letter-spacing:.02em}",
199
- ".gs-pill{margin-left:auto;font-size:9.5px;font-weight:700;text-transform:uppercase;",
220
+ // Pill + Build travel as ONE group at the header's right: the wrapper
221
+ // carries the row's only auto margin, so the 8px flex gap is the only
222
+ // thing between the pair and nothing can split them — either apart or
223
+ // across the header.
224
+ ".gs-pop-group{margin-left:auto;display:inline-flex;align-items:center;gap:8px}",
225
+ ".gs-pill{font-size:9.5px;font-weight:700;text-transform:uppercase;",
200
226
  "letter-spacing:.07em;padding:3px 8px;border-radius:999px;white-space:nowrap}",
201
- ".gs-pill.gs-p-ok{background:rgba(63,164,91,.18);color:#5cbe78}",
202
- ".gs-pill.gs-p-warn{background:rgba(208,132,66,.2);color:#e0a066}",
203
- ".gs-pill.gs-p-busy{background:rgba(75,139,214,.2);color:#79aee8}",
204
- ".gs-pill.gs-p-idle{background:rgba(128,128,128,.2);color:var(--dsh-text-3,#9a9a9a)}",
227
+ ".gs-pill.gs-p-ok{background:color-mix(in srgb,var(--dsw-alias-state-success-primary,#3fa45b) 18%,transparent);color:var(--dsw-alias-state-success-primary,#5cbe78)}",
228
+ ".gs-pill.gs-p-warn{background:color-mix(in srgb,var(--dsw-alias-state-warn-primary,#d08442) 20%,transparent);color:var(--dsw-alias-state-warn-primary,#e0a066)}",
229
+ ".gs-pill.gs-p-busy{background:color-mix(in srgb,var(--dsw-alias-state-business-primary,#4b8bd6) 20%,transparent);color:var(--dsw-alias-state-business-primary,#79aee8)}",
230
+ ".gs-pill.gs-p-idle{background:color-mix(in srgb,var(--dsw-alias-label-tertiary,#888) 20%,transparent);color:var(--dsw-alias-label-tertiary,#9a9a9a)}",
205
231
  ".gs-pop-repo{font-weight:600;font-size:12.5px;margin-bottom:2px}",
206
232
  ".gs-pop-path{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
207
- "opacity:.58;word-break:break-all}",
233
+ "color:var(--dsw-alias-label-tertiary,inherit);opacity:.58;word-break:break-all}",
208
234
  ".gs-pop-stats{display:flex;gap:18px;margin:11px 0 0;padding:10px 0 2px;",
209
- "border-top:1px solid var(--dsh-border,rgba(128,128,128,.2))}",
235
+ "border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}",
210
236
  ".gs-stat b{display:block;font-size:15px;font-weight:700;line-height:1.2;font-variant-numeric:tabular-nums}",
211
237
  ".gs-stat i{display:block;font-style:normal;font-size:9.5px;text-transform:uppercase;",
212
238
  "letter-spacing:.07em;opacity:.55;margin-top:1px}",
213
239
  ".gs-pop-note{margin-top:10px;padding-top:9px;font-size:11px;opacity:.68;",
214
- "border-top:1px solid var(--dsh-border,rgba(128,128,128,.2))}",
240
+ "border-top:1px solid var(--dsw-alias-border-l1,rgba(128,128,128,.2))}",
241
+ // The popup's Build button, grouped with the status pill at the
242
+ // header's right (see .gs-pop-group). Quiet at header scale — an
243
+ // outline button, not the brand fill — because a 24px solid pill
244
+ // would dominate the 9.5px status pill it sits beside. Disabled (and
245
+ // labelled Building…) while the build runs, so a double click cannot
246
+ // queue two of them.
247
+ //
248
+ // pointer-events:auto is load-bearing, not cosmetic: the card itself
249
+ // is pointer-events:none (pure text needs no clicks and must not eat
250
+ // them), so without this the button renders but can never be pressed.
251
+ ".gs-build{pointer-events:auto;display:inline-flex;align-items:center;height:20px;padding:0 10px;",
252
+ "border:1px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:999px;",
253
+ "background:transparent;color:var(--dsw-alias-label-secondary,inherit);",
254
+ "font-size:10.5px;font-weight:600;cursor:pointer;white-space:nowrap}",
255
+ ".gs-build:hover{background:var(--dsw-alias-interactive-bg-hover,rgba(128,128,128,.14))}",
256
+ ".gs-build:disabled{opacity:.6;cursor:progress}",
257
+ // The confirm modal's footer: Cancel beside a brand-fill accept, in
258
+ // the theme's own button language. The Modal itself (mask, card,
259
+ // Escape, focus) is dsh's chrome and needs no styles here.
260
+ ".gs-modal-foot{display:flex;gap:8px;justify-content:flex-end}",
261
+ ".gs-build.gs-confirm{background:var(--dsw-alias-brand-primary,#e5484d);border-color:transparent;color:var(--dsw-alias-label-primary-foreground,#fff)}",
215
262
  ".gs-pop-reason{font-family:ui-monospace,SFMono-Regular,monospace;font-size:10.5px;",
216
263
  "opacity:.75;word-break:break-word;margin-top:4px}",
217
264
  ].join("");
@@ -244,7 +291,113 @@ window.__ModuleLoader__.load({
244
291
  * under them as supporting detail.
245
292
  */
246
293
  function StatusCard(props) {
247
- const { status } = props;
294
+ const { status, runBuild, onSynced } = props;
295
+ const [building, setBuilding] = React.useState(false);
296
+ const [buildError, setBuildError] = React.useState(null);
297
+ // Whether the themed confirm modal is open (no index yet). Held here
298
+ // because only the no-graph path ever asks.
299
+ const [confirming, setConfirming] = React.useState(false);
300
+
301
+ // The popup's Build button runs the same rebuild the automatic hooks
302
+ // run, for this session's repo — or the plain offline initialise when
303
+ // there is no index yet. It holds Building… until OBSERVED freshness
304
+ // flips to synced — not until the build call lands, which only means
305
+ // "started". A bare `graft build` was tried here first and shipped as
306
+ // a press that changed nothing: the child exited while stats.json still
307
+ // read dirty, the button re-enabled against a pill that still said
308
+ // STALE, and the pill only moved when some later rebuild rewrote stats.
309
+ // A timed-out wait re-enables with the last error rather than holding
310
+ // Building… forever.
311
+ const proceed = React.useCallback(() => {
312
+ if (building || typeof runBuild !== "function") return;
313
+ // Indexed: rebuild at once, no question. Unindexed: indexing a tree
314
+ // writes a graft/ directory into it, so the press asks first and a
315
+ // decline builds nothing. A blind press that silently indexed whatever
316
+ // happened to be open shipped once; this gate is the reason it will
317
+ // not ship again.
318
+ if (status !== null && status.ok === true) {
319
+ startBuild();
320
+ return;
321
+ }
322
+ if (Primitives !== null && typeof Primitives.Modal === "function") {
323
+ setConfirming(true);
324
+ return;
325
+ }
326
+ // Primitives unresolvable: the ugly box is better than no gate.
327
+ if (
328
+ window.confirm(
329
+ "Build a new graft index in this workspace?\n\n" +
330
+ String(status.from ?? status.root ?? "") +
331
+ "\n\nPlain structural build — offline, no API key needed.",
332
+ )
333
+ ) {
334
+ startBuild();
335
+ }
336
+ }, [building, runBuild, status]);
337
+ const startBuild = React.useCallback(() => {
338
+ if (building || typeof runBuild !== "function") return;
339
+ setBuilding(true);
340
+ setBuildError(null);
341
+ setConfirming(false);
342
+ const finish = (ok, reason) => {
343
+ setBuilding(false);
344
+ if (!ok) setBuildError(String(reason || "unknown reason"));
345
+ };
346
+ Promise.resolve(runBuild()).then(
347
+ (answer) => {
348
+ let started;
349
+ try {
350
+ started = unwrap(answer);
351
+ } catch (error) {
352
+ finish(false, String(error?.message ?? error));
353
+ return;
354
+ }
355
+ if (!started || started.ok !== true) {
356
+ finish(false, String((started && started.reason) || "unknown reason"));
357
+ return;
358
+ }
359
+ // A fresh initialise has no stats.json yet for a freshness poll to
360
+ // observe, so there is nothing to wait for — the next status poll
361
+ // picks the new graph up on its own.
362
+ if (started.initialising === true) {
363
+ finish(true);
364
+ return;
365
+ }
366
+ if (typeof onSynced === "function") {
367
+ Promise.resolve(onSynced()).then(
368
+ (synced) => finish(synced === true, synced === true ? null : "the rebuild did not report in sync — try again"),
369
+ (error) => finish(false, String(error?.message ?? error)),
370
+ );
371
+ } else {
372
+ finish(true);
373
+ }
374
+ },
375
+ (error) => {
376
+ finish(false, String(error?.message ?? error));
377
+ },
378
+ );
379
+ }, [building, runBuild, onSynced]);
380
+
381
+ // One pair, rendered in both pill-carrying heads: pill and button
382
+ // grouped at the header's right, travelling together.
383
+ const headGroup = (pill) =>
384
+ h(
385
+ "div",
386
+ { className: "gs-pop-group" },
387
+ pill,
388
+ h(
389
+ "button",
390
+ {
391
+ type: "button",
392
+ className: "gs-build",
393
+ onClick: () => proceed(),
394
+ disabled: building,
395
+ },
396
+ building ? "Building…" : "Build",
397
+ ),
398
+ );
399
+ const buildFailure =
400
+ buildError === null ? null : h("div", { className: "gs-pop-reason" }, buildError);
248
401
  if (status === null || status === undefined) {
249
402
  return h(
250
403
  "div",
@@ -255,6 +408,54 @@ window.__ModuleLoader__.load({
255
408
  }
256
409
 
257
410
  if (status.ok !== true) {
411
+ // The themed confirm: dsh's own Modal, same chrome as every other
412
+ // dialog in the app. Rendered here (not in the head) because a modal
413
+ // portals to the body — it must not live inside the click-through
414
+ // card. Cancel/Escape/mask-click closes and builds nothing; the
415
+ // accept starts the initialise.
416
+ const confirmModal =
417
+ Primitives !== null && typeof Primitives.Modal === "function"
418
+ ? h(
419
+ Primitives.Modal,
420
+ {
421
+ open: confirming,
422
+ onClose: () => {
423
+ if (!building) setConfirming(false);
424
+ },
425
+ title: "Build graft index?",
426
+ description:
427
+ "Index " +
428
+ basename(String(status.from ?? status.root ?? "")) +
429
+ " with a plain structural build — offline, no API key needed. Writes a graft/ directory there.",
430
+ closeLabel: "Close",
431
+ footer: h(
432
+ "div",
433
+ { className: "gs-modal-foot" },
434
+ h(
435
+ "button",
436
+ {
437
+ type: "button",
438
+ className: "gs-build",
439
+ onClick: () => setConfirming(false),
440
+ disabled: building,
441
+ },
442
+ "Cancel",
443
+ ),
444
+ h(
445
+ "button",
446
+ {
447
+ type: "button",
448
+ className: "gs-build gs-confirm",
449
+ onClick: () => startBuild(),
450
+ disabled: building,
451
+ "data-modal-autofocus": true,
452
+ },
453
+ building ? "Building…" : "Build index",
454
+ ),
455
+ ),
456
+ },
457
+ )
458
+ : null;
258
459
  return h(
259
460
  "div",
260
461
  { className: "gs-pop", role: "tooltip" },
@@ -262,11 +463,16 @@ window.__ModuleLoader__.load({
262
463
  "div",
263
464
  { className: "gs-pop-head" },
264
465
  h("span", { className: "gs-pop-title" }, "graft"),
265
- h("span", { className: "gs-pill gs-p-idle" }, "no graph"),
466
+ headGroup(h("span", { className: "gs-pill gs-p-idle" }, "no graph")),
266
467
  ),
267
468
  h("div", null, "No graft index for this workspace."),
268
469
  h("div", { className: "gs-pop-reason" }, String(status.reason ?? "unknown reason")),
470
+ status.worktreeOf
471
+ ? h("div", { className: "gs-pop-note" }, "git worktree of " + basename(status.worktreeOf) + " — /graft here seeds the first graph from it.")
472
+ : null,
269
473
  h("div", { className: "gs-pop-note" }, "Run `graft build` in it once — no API key needed."),
474
+ buildFailure,
475
+ confirmModal,
270
476
  );
271
477
  }
272
478
 
@@ -281,10 +487,13 @@ window.__ModuleLoader__.load({
281
487
  "div",
282
488
  { className: "gs-pop-head" },
283
489
  h("span", { className: "gs-pop-title" }, "graft"),
284
- h("span", { className: "gs-pill " + pill.className }, pill.label),
490
+ headGroup(h("span", { className: "gs-pill " + pill.className }, pill.label)),
285
491
  ),
286
492
  h("div", { className: "gs-pop-repo" }, basename(status.root)),
287
493
  h("div", { className: "gs-pop-path" }, status.root),
494
+ status.worktreeOf
495
+ ? h("div", { className: "gs-pop-note" }, "git worktree of " + basename(status.worktreeOf))
496
+ : null,
288
497
  h(
289
498
  "div",
290
499
  { className: "gs-pop-stats" },
@@ -299,24 +508,29 @@ window.__ModuleLoader__.load({
299
508
  // drift signal at all, so "in sync" is an absence of evidence.
300
509
  ? h("div", { className: "gs-pop-note" }, "Read from the graph itself; no live drift signal.")
301
510
  : null,
511
+ buildFailure,
302
512
  );
303
513
  }
304
514
 
305
- /** The graph mark, exactly as supplied. `--accent` is set by `.gs-viz`. */
515
+ /** The graft mark: thick trunk with a leaf, thin branches, accent node.
516
+ * The same design as `assets/icon.svg` and the banners, at button scale
517
+ * (trunk and branch widths match the tuned vector's tube widths).
518
+ * `--accent` is set by `.gs-viz`. */
306
519
  function GraphIcon() {
307
520
  return h(
308
521
  "svg",
309
522
  { width: 19, height: 19, viewBox: "0 0 24 24", "aria-hidden": "true" },
523
+ h("path", { d: "M11.6 17.8 C9.9 17.9 8.2 17.1 7.3 15.4 C9.2 14.9 11 15.9 11.6 17.8 Z", fill: "var(--accent)" }),
310
524
  h(
311
525
  "g",
312
- { fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" },
313
- h("path", { d: "M12 21 V12" }),
314
- h("path", { d: "M12 12 L6.8 6.6" }),
315
- h("path", { stroke: "var(--accent)", d: "M12 12 L17.4 7.6" }),
526
+ { fill: "none", stroke: "currentColor", strokeLinecap: "round" },
527
+ h("path", { strokeWidth: 3.1, d: "M12 21 V12" }),
528
+ h("path", { strokeWidth: 1.7, d: "M12 12 L6.8 6.6" }),
529
+ h("path", { stroke: "var(--accent)", strokeWidth: 1.8, d: "M12 12 L17.4 7.6" }),
316
530
  ),
317
- h("circle", { cx: 12, cy: 12, r: 2.1, fill: "currentColor" }),
318
- h("circle", { cx: 6.8, cy: 6.6, r: 1.7, fill: "currentColor" }),
319
- h("circle", { cx: 17.4, cy: 7.6, r: 1.7, fill: "var(--accent)" }),
531
+ h("circle", { cx: 12, cy: 12, r: 2.2, fill: "currentColor" }),
532
+ h("circle", { cx: 6.8, cy: 6.6, r: 1.8, fill: "currentColor" }),
533
+ h("circle", { cx: 17.4, cy: 7.6, r: 1.8, fill: "var(--accent)" }),
320
534
  );
321
535
  }
322
536
 
@@ -329,11 +543,16 @@ window.__ModuleLoader__.load({
329
543
  * stares at a blank white tab and assumes it broke.
330
544
  */
331
545
  function waitingDocument(repo) {
546
+ // A popped-out document cannot see the app's theme variables; the best
547
+ // it can do is follow the OS preference.
548
+ const dark = window.matchMedia !== undefined && window.matchMedia("(prefers-color-scheme: dark)").matches;
549
+ const bg = dark ? "#161616" : "#f6f6f6";
550
+ const fg = dark ? "#ddd" : "#333";
332
551
  return [
333
552
  "<!doctype html><meta charset=utf-8>",
334
553
  "<title>graft viz — ", repo, "</title>",
335
554
  "<style>body{margin:0;display:grid;place-items:center;height:100vh;",
336
- "font:14px system-ui,sans-serif;background:#161616;color:#ddd}</style>",
555
+ "font:14px system-ui,sans-serif;background:", bg, ";color:", fg, "}</style>",
337
556
  "<div>Starting <b>graft viz</b> for ", repo, "…</div>",
338
557
  ].join("");
339
558
  }
@@ -378,6 +597,15 @@ window.__ModuleLoader__.load({
378
597
 
379
598
  const FRESH_MARK = { synced: "✓ synced", stale: "▲ stale", syncing: "◐ syncing", unknown: "○ unknown" };
380
599
 
600
+ /**
601
+ * How long the Build button waits for observed sync before giving up.
602
+ *
603
+ * Two minutes of 2s polls: a structural rebuild of a normal repo takes
604
+ * about a second, a large one can take minutes, and a wedged one must not
605
+ * hold Building… forever.
606
+ */
607
+ const SYNC_WAIT_MS = 120000;
608
+
381
609
  /** ⟳ — the one control adopted from the sidebar's own tab headers. */
382
610
  function RefreshIcon() {
383
611
  return h(
@@ -461,6 +689,7 @@ window.__ModuleLoader__.load({
461
689
  const ok = status !== null && status.ok === true;
462
690
  const freshness = ok ? status.freshness ?? "unknown" : "unknown";
463
691
  const saved = ok ? tokensFull(status.savedTokens) : null;
692
+ const wt = status !== null && status.worktreeOf ? basename(status.worktreeOf) : null;
464
693
 
465
694
  const bar = h(
466
695
  "div",
@@ -476,8 +705,10 @@ window.__ModuleLoader__.load({
476
705
  h("span", { className: "gs-tb-fresh " + (TONE[freshness] ?? TONE.unknown) }, FRESH_MARK[freshness] ?? FRESH_MARK.unknown),
477
706
  saved === null ? null : h("span", { className: "gs-tb-sep" }, "·"),
478
707
  saved === null ? null : h("span", { className: "gs-tb-saved" }, saved),
708
+ wt === null ? null : h("span", { className: "gs-tb-sep" }, "·"),
709
+ wt === null ? null : h("span", { className: "gs-tb-dim" }, "worktree of " + wt),
479
710
  )
480
- : h("span", { className: "gs-tb-dim" }, status === null ? "…" : "no graph"),
711
+ : h("span", { className: "gs-tb-dim" }, status === null ? "…" : wt === null ? "no graph" : "no graph — worktree of " + wt),
481
712
  h(
482
713
  "button",
483
714
  {
@@ -520,6 +751,21 @@ window.__ModuleLoader__.load({
520
751
  const dwell = React.useRef(null);
521
752
  const wrap = React.useRef(null);
522
753
 
754
+ // Whether the dot should be blinking violet right now: a graft tool call
755
+ // was seen and its blink window has not run out yet.
756
+ const [flick, setFlick] = React.useState(false);
757
+ // Whether a graft tool was seen recently. Outlives the blink on
758
+ // purpose: it holds the fast poll for a tail after the last pulse, so
759
+ // the NEXT call in an active burst is caught within one fast tick
760
+ // instead of waiting out the slow settled cadence — exactly the lag a
761
+ // second tool call used to show.
762
+ const [recent, setRecent] = React.useState(false);
763
+ const recentTimer = React.useRef(null);
764
+ // The last pulse consumed, as { root, count }: the blink fires when the
765
+ // count MOVES for the repo this chip describes, never on first sight.
766
+ const pulseSeen = React.useRef(null);
767
+ const flickTimers = React.useRef([]);
768
+
523
769
  // Whether the graph is at rest. Drives the poll cadence below.
524
770
  const settled = status !== null && status.ok === true && status.freshness === "synced";
525
771
 
@@ -560,7 +806,14 @@ window.__ModuleLoader__.load({
560
806
  const onDocument = (event) => {
561
807
  // `contains` rather than a target comparison, so a click on the
562
808
  // card's own text — or on the chip, which toggles — is not treated
563
- // as "outside" and closed twice.
809
+ // as "outside" and closed twice. Clicks inside the confirm modal
810
+ // are ALSO inside: it portals to document.body, outside the wrap,
811
+ // so without this guard the capture-phase closer unmounts the card
812
+ // (modal included) before the accept's own click ever dispatches —
813
+ // an accept that hides the dialog and builds nothing.
814
+ const inModal =
815
+ event.target instanceof Element && event.target.closest('[role="dialog"], .gs-modal-foot') !== null;
816
+ if (inModal) return;
564
817
  if (wrap.current !== null && wrap.current.contains(event.target)) return;
565
818
  setCard(false);
566
819
  };
@@ -573,6 +826,61 @@ window.__ModuleLoader__.load({
573
826
  // component; one that fires after the session switched would show the
574
827
  // previous workspace's card.
575
828
  React.useEffect(() => () => window.clearTimeout(dwell.current), []);
829
+ // The blink must not cross a session boundary — this component stays
830
+ // mounted across one — and its timers must not outlive it. Effect
831
+ // cleanup runs for both, so this one block is the whole hygiene.
832
+ React.useEffect(
833
+ () => () => {
834
+ for (const t of flickTimers.current) window.clearTimeout(t);
835
+ flickTimers.current = [];
836
+ window.clearTimeout(recentTimer.current);
837
+ pulseSeen.current = null;
838
+ setFlick(false);
839
+ setRecent(false);
840
+ },
841
+ [sessionId],
842
+ );
843
+
844
+ // Blink the dot violet on and off — intermittent, never solid — for just
845
+ // under two seconds, toggling every 175ms. The mark list is EVEN on
846
+ // purpose: the final toggle is always "off", so the dot returns to its
847
+ // resting colour instead of sticking on violet. A pulse arriving
848
+ // mid-blink restarts the run, so a burst of graft calls reads as one
849
+ // flicker that ends when they do.
850
+ const blink = React.useCallback(() => {
851
+ for (const t of flickTimers.current) window.clearTimeout(t);
852
+ flickTimers.current = [];
853
+ for (const ms of [0, 175, 350, 525, 700, 875, 1050, 1225, 1400, 1575, 1750, 1925]) {
854
+ const on = flickTimers.current.length % 2 === 0;
855
+ flickTimers.current.push(window.setTimeout(() => setFlick(on), ms));
856
+ }
857
+ }, []);
858
+
859
+ // Consume one polled status: blink when its tool-use count has moved
860
+ // for THIS repo. A first look — or the first look after the chip
861
+ // switched repos — only sets the baseline, so history never blinks.
862
+ const pulseCheck = React.useCallback(
863
+ (next) => {
864
+ const count = next?.toolUse?.count;
865
+ if (typeof count !== "number") return;
866
+ const seen = pulseSeen.current;
867
+ if (seen === null || seen.root !== next.root) {
868
+ pulseSeen.current = { root: next.root, count };
869
+ return;
870
+ }
871
+ if (count > seen.count) {
872
+ pulseSeen.current = { root: next.root, count };
873
+ blink();
874
+ // Hold the fast poll for a tail after the last use, so the next
875
+ // call in a burst is seen within one tick of the harness's own
876
+ // tool feedback.
877
+ setRecent(true);
878
+ window.clearTimeout(recentTimer.current);
879
+ recentTimer.current = window.setTimeout(() => setRecent(false), 20000);
880
+ }
881
+ },
882
+ [blink],
883
+ );
576
884
 
577
885
  React.useEffect(() => {
578
886
  let live = true;
@@ -584,7 +892,10 @@ window.__ModuleLoader__.load({
584
892
  }
585
893
  Promise.resolve(service.status(sessionId)).then(
586
894
  (answer) => {
587
- if (live) setStatus(unwrap(answer));
895
+ if (!live) return;
896
+ const next = unwrap(answer);
897
+ setStatus(next);
898
+ pulseCheck(next);
588
899
  },
589
900
  (error) => {
590
901
  if (live) setStatus({ ok: false, reason: String(error?.message ?? error) });
@@ -598,8 +909,13 @@ window.__ModuleLoader__.load({
598
909
  // near zero and the chip appeared to jump straight from stale back
599
910
  // to green. While the graph is MOVING — stale, mid-sync, or not yet
600
911
  // known — it is watched closely; once it is in sync there is nothing
601
- // to watch and the slow interval is right again.
602
- const timer = window.setInterval(pull, settled ? 15000 : 2000);
912
+ // to watch. Even at rest it still polls at 4s rather than the old
913
+ // 15s, because graft tool feedback must keep up with the harness's
914
+ // own tool feedback — a settled chip that only looked twice a minute
915
+ // was the reported lag on every call after the first. The server
916
+ // caches status for 5s, so 4s adds no real reads; a pulse then holds
917
+ // the 2s cadence for a 20s tail.
918
+ const timer = window.setInterval(pull, settled && !recent ? 4000 : 2000);
603
919
  return () => {
604
920
  live = false;
605
921
  window.clearInterval(timer);
@@ -607,7 +923,50 @@ window.__ModuleLoader__.load({
607
923
  // `settled` is a dependency, not just a value read inside: without it
608
924
  // the interval would keep whatever cadence it had at mount — 2s, from
609
925
  // the initial unknown state — and never slow down again.
610
- }, [sessionId, settled]);
926
+ }, [sessionId, settled, recent, pulseCheck]);
927
+
928
+ // The build the popup button triggers. Resolved here, in a callback
929
+ // like every other remote access, rather than in render.
930
+ const runBuild = React.useCallback(() => {
931
+ const service = ctx.get("remote.graftStatus");
932
+ if (service === undefined || service === null) {
933
+ return Promise.reject(new Error("graft-status host half is not mounted"));
934
+ }
935
+ return service.build(sessionId);
936
+ }, [sessionId]);
937
+
938
+ // Re-read status on demand — after the popup's Build lands — rather
939
+ // than waiting out the poll. Returns the observed freshness, so a
940
+ // waiter can hold until the rebuild is VISIBLE, not just started.
941
+ const repull = React.useCallback(() => {
942
+ const service = ctx.get("remote.graftStatus");
943
+ if (service === undefined || service === null) return Promise.resolve(null);
944
+ return Promise.resolve(service.status(sessionId)).then(
945
+ (answer) => {
946
+ const next = unwrap(answer);
947
+ setStatus(next);
948
+ pulseCheck(next);
949
+ return next !== null && next.ok === true ? next.freshness ?? "unknown" : null;
950
+ },
951
+ () => null,
952
+ );
953
+ }, [sessionId, pulseCheck]);
954
+
955
+ // Hold until a re-read OBSERVES `synced`, not until the build call
956
+ // lands: the rebuild runs detached, so "started" says nothing about
957
+ // what stats.json reads yet. Bounded — two minutes of 2s polls — so a
958
+ // wedged rebuild re-enables the button with the last error rather
959
+ // than holding Building… forever.
960
+ const waitForSynced = React.useCallback(() => {
961
+ const started = Date.now();
962
+ const look = () =>
963
+ Promise.resolve(repull()).then((freshness) => {
964
+ if (freshness === "synced") return true;
965
+ if (Date.now() - started > SYNC_WAIT_MS) return false;
966
+ return new Promise((resolve) => window.setTimeout(() => resolve(look().then(resolve)), 2000));
967
+ });
968
+ return look();
969
+ }, [repull]);
611
970
 
612
971
  const openViz = React.useCallback(() => {
613
972
  const repo = basename((status && status.root) || "");
@@ -764,11 +1123,21 @@ window.__ModuleLoader__.load({
764
1123
  return h(
765
1124
  "div",
766
1125
  { className: "gs-wrap", ref: wrap },
767
- card ? h(StatusCard, { status }) : null,
1126
+ card
1127
+ ? h(StatusCard, {
1128
+ status,
1129
+ runBuild,
1130
+ // The button holds Building… until this OBSERVES synced, so
1131
+ // the pill flips first and the button follows — never the
1132
+ // other way round.
1133
+ onSynced: waitForSynced,
1134
+ })
1135
+ : null,
768
1136
  h(
769
1137
  "div",
770
1138
  {
771
- className: "gs-chip gs-clickable " + tone + (ok ? "" : " gs-muted"),
1139
+ className:
1140
+ "gs-chip gs-clickable " + tone + (ok ? "" : " gs-muted") + (flick ? " gs-live" : ""),
772
1141
  onMouseEnter: enter,
773
1142
  onMouseLeave: leave,
774
1143
  onClick: toggleCard,
@@ -792,7 +1161,7 @@ window.__ModuleLoader__.load({
792
1161
  h("span", { className: "gs-dot", "aria-hidden": "true" }, glyph),
793
1162
  h("span", { className: "gs-name" }, "graft"),
794
1163
  h("span", { className: "gs-sep" }, "·"),
795
- h("span", null, label),
1164
+ h("span", { className: "gs-label" }, label),
796
1165
  ok && freshness === "stale" ? h("span", { className: "gs-sep" }, "·") : null,
797
1166
  ok && freshness === "stale" ? h("span", null, "stale") : null,
798
1167
  ),