@monster-orange/den 0.4.22 → 0.4.24

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.
@@ -67,6 +67,9 @@ const S = {
67
67
  testing: new Set(),
68
68
  /** The open panel: { id, el, opener, scrim, timer }. */
69
69
  open: null,
70
+ /** D27: a pointer is down inside the panel: repaint after it is up. */
71
+ pressed: false,
72
+ syncLater: false,
70
73
  };
71
74
 
72
75
  /** Start or Restart from here: "starting…" until health says, or the timeout. */
@@ -228,14 +231,21 @@ function testsHtml(s) {
228
231
  return `<span class="den-svp-${ok ? "ok" : "bad"}">${esc(t(ok ? "svp.testsOk" : "svp.testsBad", { t: at }))}</span>`;
229
232
  }
230
233
 
231
- /** A Visit button (the rail row's ↗, or the panel's primary). */
234
+ /**
235
+ * Visit: a real link, styled as a button (D27). A native target=_blank link
236
+ * is never popup-blocked (Safari blocked window.open from the delegated
237
+ * click) and works with no script. Off (down, starting): no href,
238
+ * aria-disabled, and a click says why (visit()).
239
+ */
232
240
  export function visitHtml(s, cls, label) {
233
241
  if (!s.url) return "";
234
- const on = visitable(s);
242
+ const on = visitable(s) && /^https?:\/\//i.test(s.url);
235
243
  return (
236
- `<button type="button" class="${cls}" data-visit="${esc(s.id)}" data-url="${esc(s.url)}"` +
237
- ` aria-label="${esc(t("act.visitX", { s: s.id }))}" title="${esc(on ? s.url : t("svp.notRunning"))}"` +
238
- `${on ? "" : ' aria-disabled="true"'}>${label}</button>`
244
+ `<a class="${cls}" data-visit="${esc(s.id)}" data-url="${esc(s.url)}"` +
245
+ (on
246
+ ? ` href="${esc(s.url)}" target="_blank" rel="noopener noreferrer"`
247
+ : ` role="link" tabindex="0" aria-disabled="true"`) +
248
+ ` aria-label="${esc(t("act.visitX", { s: s.id }))}" title="${esc(on ? s.url : t("svp.notRunning"))}">${label}</a>`
239
249
  );
240
250
  }
241
251
 
@@ -335,6 +345,12 @@ function sync() {
335
345
  publishUrls();
336
346
  const o = S.open;
337
347
  if (!o) return;
348
+ // D27: never repaint under a press: the button pressed would be replaced
349
+ // between pointerdown and click, and the click would land on the panel.
350
+ if (S.pressed) {
351
+ S.syncLater = true;
352
+ return;
353
+ }
338
354
  const a = document.activeElement;
339
355
  const mine = a && o.el.contains(a) && a !== o.el;
340
356
  const key = mine
@@ -451,15 +467,20 @@ export function panelOpen() {
451
467
  const env = () => S.deps.currentEnv?.() ?? "local";
452
468
  const runs = () => window.denShell?.runs;
453
469
 
454
- /** Visit: the service's URL in a new tab, or why not. */
470
+ /**
471
+ * Visit, clicked: a live link opens by itself (the browser follows it, so
472
+ * true: leave the click alone); an off one says why (false: the caller
473
+ * prevents the click).
474
+ */
455
475
  export function visit(btn) {
456
- if (btn.getAttribute("aria-disabled") === "true") {
476
+ if (
477
+ btn.getAttribute("aria-disabled") === "true" ||
478
+ !btn.hasAttribute("href")
479
+ ) {
457
480
  window.denShell?.toast?.(t("svp.notRunning"));
458
- return;
481
+ return false;
459
482
  }
460
- const url = btn.dataset.url;
461
- if (!url || !/^https?:\/\//i.test(url)) return;
462
- window.open(url, "_blank", "noopener");
483
+ return true;
463
484
  }
464
485
 
465
486
  /** A stream into a runs-dock tab: Start, Stop, Restart (exec), Logs, tests. */
@@ -635,6 +656,27 @@ export function initServicePanel(deps = {}) {
635
656
  sync();
636
657
  });
637
658
  window.addEventListener("den:pins", () => sync());
659
+ // D27: a press inside the open panel holds its repaint until it ends.
660
+ document.addEventListener(
661
+ "pointerdown",
662
+ (e) => {
663
+ if (S.open?.el.contains(e.target)) S.pressed = true;
664
+ },
665
+ true,
666
+ );
667
+ const released = () => {
668
+ if (!S.pressed) return;
669
+ S.pressed = false;
670
+ // After the click this press makes, then the repaint it held.
671
+ setTimeout(() => {
672
+ if (S.syncLater) {
673
+ S.syncLater = false;
674
+ sync();
675
+ }
676
+ }, 0);
677
+ };
678
+ document.addEventListener("pointerup", released, true);
679
+ document.addEventListener("pointercancel", released, true);
638
680
  document.addEventListener(
639
681
  "click",
640
682
  (e) => {
@@ -643,10 +685,7 @@ export function initServicePanel(deps = {}) {
643
685
  // The panel's Visit here; the rail's ↗ is shell.js's (the same visit()).
644
686
  const v = tgt.closest?.("[data-visit]");
645
687
  if (v) {
646
- if (o?.el.contains(v)) {
647
- e.preventDefault();
648
- visit(v);
649
- }
688
+ if (o?.el.contains(v) && !visit(v)) e.preventDefault();
650
689
  return;
651
690
  }
652
691
  const opener = tgt.closest?.("[data-svpanel-open]");
@@ -374,6 +374,25 @@ export const ROUTES: readonly RouteEntry[] = [
374
374
  "forgets every stopped agent of the workspaces named (AG-14.1b)",
375
375
  ),
376
376
  M(["POST"], "/api/agents/restore", "Undo: brings removed agents back"),
377
+ M(
378
+ ["POST"],
379
+ "/api/workspaces/:ws/agents/:id/keep",
380
+ "D28 Keep this conversation: resets its rotation count (and, asked, sets its override to never); a person's only",
381
+ ),
382
+ S(
383
+ ["POST"],
384
+ "/api/workspaces/:ws/agents/:id/handoff",
385
+ "D28 Write handoff: types the handoff prompt into the agent's running session",
386
+ ),
387
+ R(
388
+ "/api/agents/rotation",
389
+ "D28: rotation settings, the overrides and an agent's effective values",
390
+ ),
391
+ M(
392
+ ["PUT"],
393
+ "/api/agents/rotation",
394
+ "D28: sets or clears one rotation setting at one scope; a person's only",
395
+ ),
377
396
  S(
378
397
  ["POST"],
379
398
  "/api/workspaces/:ws/agents/:id/fresh",
@@ -504,6 +523,8 @@ export const REGEX_ROUTES: Readonly<Record<string, readonly string[]>> = {
504
523
  "/api/workspaces/:ws/agents/:id/name",
505
524
  "/api/workspaces/:ws/agents/:id/paths",
506
525
  "/api/workspaces/:ws/agents/:id/persona",
526
+ "/api/workspaces/:ws/agents/:id/keep",
527
+ "/api/workspaces/:ws/agents/:id/handoff",
507
528
  ],
508
529
  "agent-routes.ts:RUNTIME_PATH": ["/api/agents/runtimes/:id/path"],
509
530
  "agent-routes.ts:PERSONA_PATH": ["/api/personas/:id"],
@@ -50,7 +50,11 @@ import { revokedAgentIds } from "../../shared/actor-token.js";
50
50
  import { listRecords } from "../../shared/agents/records.js";
51
51
  import { softNewlineSeq } from "../../shared/agents/catalog.js";
52
52
  import { getRuntime } from "../../shared/agents/runtimes.js";
53
- import type { RotationInfo } from "../../shared/agents/rotation.js";
53
+ import type {
54
+ RotationEntry,
55
+ RotationInfo,
56
+ RotationPolicy,
57
+ } from "../../shared/agents/rotation.js";
54
58
  import {
55
59
  connectHost,
56
60
  noHost,
@@ -61,7 +65,7 @@ import { sessionPaths } from "../../shared/sessions/paths.js";
61
65
  import { loadPty, type PtyLoad } from "../../shared/sessions/pty.js";
62
66
  import type { SessionInfo } from "../../shared/sessions/types.js";
63
67
  import { agentRows, newerOf, noteEnded } from "../cli/agent-ops.js";
64
- import type { Override } from "../../shared/agents/records.js";
68
+ import type { AgentRecord, Override } from "../../shared/agents/records.js";
65
69
  import { state as sessionState } from "../cli/sessions-command.js";
66
70
  import { recordAction } from "./audit.js";
67
71
  import { sendJson } from "./http-helpers.js";
@@ -85,6 +89,8 @@ export interface SessionRouteDeps {
85
89
  pty?: () => PtyLoad;
86
90
  /** The agent ids with an approval waiting for the person (AG-6a, ON-3). */
87
91
  waitingAgents?: () => ReadonlySet<string>;
92
+ /** D28: each agent's effective rotation settings (one resolver per list). */
93
+ rotationPolicy?: () => (rec: AgentRecord) => RotationPolicy | undefined;
88
94
  }
89
95
 
90
96
  interface Resolved {
@@ -95,6 +101,7 @@ interface Resolved {
95
101
  workspaceExists: (ws: string) => boolean;
96
102
  pty: () => PtyLoad;
97
103
  waitingAgents: () => ReadonlySet<string>;
104
+ rotationPolicy?: SessionRouteDeps["rotationPolicy"];
98
105
  }
99
106
 
100
107
  /** A workspace name as `?ws=` may give it. */
@@ -125,6 +132,7 @@ function resolve(d: SessionRouteDeps = {}): Resolved {
125
132
  )),
126
133
  pty: d.pty ?? (() => loadPty()),
127
134
  waitingAgents: d.waitingAgents ?? (() => new Set<string>()),
135
+ ...(d.rotationPolicy ? { rotationPolicy: d.rotationPolicy } : {}),
128
136
  };
129
137
  }
130
138
 
@@ -199,13 +207,16 @@ export interface ListedSession {
199
207
  * Relaunch from handoff is its action, not Resume.
200
208
  */
201
209
  rotation?: RotationInfo;
210
+ /** D28: the last relaunch from a handoff (the page toasts one Den did by itself). */
211
+ lastRotation?: RotationEntry;
202
212
  }
203
213
 
204
214
  /** The list, from the host's sessions and the agent records. */
205
215
  export function listedSessions(
206
216
  sessions: SessionInfo[],
207
217
  d: Pick<Resolved, "stateDir" | "dist" | "activeWorkspaces"> &
208
- Partial<Pick<Resolved, "waitingAgents">>,
218
+ Partial<Pick<Resolved, "waitingAgents">> &
219
+ Pick<SessionRouteDeps, "rotationPolicy">,
209
220
  /** A workspace asked for by name (`?ws=`), listed even when inactive. */
210
221
  also?: string,
211
222
  ): ListedSession[] {
@@ -226,7 +237,12 @@ export function listedSessions(
226
237
  const out: ListedSession[] = [];
227
238
  const agentSessions = new Set<string>();
228
239
  noteEnded(d.stateDir(), mine, records);
229
- for (const r of agentRows(mine, records, d.stateDir())) {
240
+ for (const r of agentRows(
241
+ mine,
242
+ records,
243
+ d.stateDir(),
244
+ d.rotationPolicy?.(),
245
+ )) {
230
246
  const rec = records.find((x) => x.id === r.agent)!;
231
247
  const s = r.session;
232
248
  if (s) agentSessions.add(s.ref.id);
@@ -262,7 +278,9 @@ export function listedSessions(
262
278
  ...(r.stoppedAt ? { stoppedAt: r.stoppedAt } : {}),
263
279
  ...(r.endedAt ? { endedAt: r.endedAt } : {}),
264
280
  ...(r.interruptedAt ? { interruptedAt: r.interruptedAt } : {}),
265
- ...(r.rotation?.due ? { rotation: r.rotation } : {}),
281
+ // D28: always (its effective settings show), due or not.
282
+ ...(r.rotation ? { rotation: r.rotation } : {}),
283
+ ...(r.lastRotation ? { lastRotation: r.lastRotation } : {}),
266
284
  ...(revoked.has(rec.id) && s && !s.exited ? { tokenRevoked: true } : {}),
267
285
  ...(waiting.has(rec.id) && s && !s.exited ? { waiting: true } : {}),
268
286
  });
@@ -52,6 +52,8 @@ export const UI_MODULES: readonly string[] = [
52
52
  "/server-agent-page.js",
53
53
  // 0.4.18: the service panel (rail pins, Graph ⋯, Services names).
54
54
  "/svc-panel.js",
55
+ // D30: the shared searchable picker (Settings › Agents › Rotation).
56
+ "/combobox.js",
55
57
  // 0.4.18: where a service's live log comes from (Logs page and panel).
56
58
  "/log-source.js",
57
59
  ];