@monster-orange/den 0.4.15 → 0.4.17

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +125 -0
  2. package/README.md +7 -7
  3. package/docs/distributions.md +137 -11
  4. package/package.json +1 -1
  5. package/src/shared/agents/conversations.ts +122 -4
  6. package/src/shared/agents/den-claude.ts +13 -1
  7. package/src/shared/agents/records.ts +19 -0
  8. package/src/shared/agents/runtime.ts +5 -0
  9. package/src/shared/github-app.ts +7 -0
  10. package/src/ui/cli/agent-ops.ts +304 -1
  11. package/src/ui/cli/agents-command.ts +71 -7
  12. package/src/ui/server/agent-routes.ts +92 -2
  13. package/src/ui/server/check-pool.ts +72 -0
  14. package/src/ui/server/github-routes.ts +1 -1
  15. package/src/ui/server/index.ts +127 -15
  16. package/src/ui/server/public/agents-page.js +358 -38
  17. package/src/ui/server/public/agents.html +10 -0
  18. package/src/ui/server/public/environments.html +223 -140
  19. package/src/ui/server/public/error-page.js +70 -15
  20. package/src/ui/server/public/error.html +18 -0
  21. package/src/ui/server/public/graph.css +18 -0
  22. package/src/ui/server/public/graph.js +23 -6
  23. package/src/ui/server/public/i18n/es.js +47 -5
  24. package/src/ui/server/public/i18n/ja.js +47 -5
  25. package/src/ui/server/public/index.html +6 -0
  26. package/src/ui/server/public/local-dev.html +188 -25
  27. package/src/ui/server/public/panel.js +1 -1
  28. package/src/ui/server/public/persona-ui.css +11 -0
  29. package/src/ui/server/public/release.html +19 -1
  30. package/src/ui/server/public/shell.js +187 -12
  31. package/src/ui/server/public/topbar.js +34 -1
  32. package/src/ui/server/public/view-scope.js +61 -0
  33. package/src/ui/server/public/workspace-picker.js +2 -0
  34. package/src/ui/server/route-classes.ts +8 -0
  35. package/src/ui/server/run-history.ts +181 -0
  36. package/src/ui/server/session-routes.ts +97 -17
  37. package/src/ui/server/ui-assets.ts +2 -0
@@ -53,6 +53,8 @@
53
53
  "b.down.one": "{n} caído",
54
54
  "act.pin": "Fijar",
55
55
  "act.unpin": "Quitar",
56
+ "act.visit": "Visitar",
57
+ "act.visitX": "Visitar {s} (se abre en una pestaña nueva)",
56
58
  "act.pinX": "Fijar {s}",
57
59
  "act.unpinX": "Quitar {s}",
58
60
  "toast.pinned": "{s} fijado",
@@ -109,6 +111,8 @@
109
111
  "kind.app": "App",
110
112
  "kind.action": "Acción",
111
113
  "kind.setting": "Ajuste",
114
+ "kind.workspace": "Espacio de trabajo",
115
+ "pal.goWs": "Ir al espacio de trabajo {ws}",
112
116
  "pal.repos": "{n} repositorios",
113
117
  "pal.repos.one": "{n} repositorio",
114
118
  "pal.restart": "Reiniciar {s}",
@@ -254,12 +258,18 @@
254
258
  "err.signin.title": "Inicia sesión en {name}",
255
259
  "err.signin.where":
256
260
  "Estás abriendo {name} desde otro ordenador. {name} corre en {host}, así que necesita saber que eres tú.",
257
- "err.signin.githubNote":
258
- "Usa tu cuenta de GitHub. El propietario de este {name} tiene que haber vinculado la suya.",
259
- "err.signin.githubNoOwner":
260
- "Iniciar sesión con GitHub necesita un propietario, y este {name} aún no tiene. En {host} (no en este ordenador), ejecuta:",
261
+ "err.signin.ghStep1":
262
+ "En {host}, ejecuta {cmd}, o usa Ajustes › Acceso allí.",
263
+ "err.signin.ghStep2":
264
+ "Muestra un código. Introdúcelo en github.com/login/device y apruébalo.",
265
+ "err.signin.ghStep3":
266
+ "Después vuelve aquí y pulsa Iniciar sesión con GitHub, con la misma cuenta de GitHub.",
267
+ "err.signin.ghOwner": "Propietario: @{login}",
268
+ "err.signin.ghOnly":
269
+ "Solo la cuenta de GitHub del propietario puede iniciar sesión así. Para cualquier otra persona, usa «Aprueba este ordenador».",
270
+ "err.signin.ghLearn": "Más información",
261
271
  "err.signin.githubRefused":
262
- "@{login} no es el propietario de este {name}. Pide al propietario que te añada, o usa «Aprobar este ordenador».",
272
+ "@{login} no es el propietario de este {name}. Solo la cuenta del propietario puede usar Iniciar sesión con GitHub. Usa «Aprueba este ordenador» en su lugar.",
263
273
  "err.signin.approveOr": "O aprueba este ordenador",
264
274
  "err.signin.approve": "Aprueba este ordenador",
265
275
  "err.signin.step1": "En {host} (no en este ordenador), ejecuta:",
@@ -489,6 +499,34 @@
489
499
  "ag.bring.agent": "Agente {id}",
490
500
  "ag.bring.note":
491
501
  "La continúa en la carpeta donde se ejecutó, con las herramientas y protecciones de Den desde ahora. Sigue en ejecución si cierras el panel. Ciérrala antes en cualquier otro terminal: Den solo la ve abierta si se escribió en el último minuto.",
502
+ "ag.keep.model": "Mantiene {m} de la conversación.",
503
+ "ag.keep.modelNew": "Usa {m} a partir de ahora.",
504
+ "ag.keep.from":
505
+ "A partir de ahora trabaja como {p}: su rol, sus permisos ({mode}), las herramientas y protecciones de Den. Su historial se mantiene.",
506
+ "ag.keep.lost":
507
+ "No tendrá: {s}. Den ejecuta los agentes solo con las herramientas de Den.",
508
+ "ag.keep.opt": "Mantener {m}",
509
+ "ag.keep.optNone": "Mantener el modelo que usa",
510
+ "ag.keep.advNote":
511
+ "El modelo que usa se mantiene salvo que elijas otro aquí. Los permisos vienen de la persona.",
512
+ "ag.pc.item": "Cambiar persona…",
513
+ "ag.pc.title": "Cambiar la persona de {id}",
514
+ "ag.pc.note":
515
+ "Den lo detiene y reanuda la misma conversación con la nueva persona. Su id, nombre, carpeta y pestaña se mantienen.",
516
+ "ag.pc.go": "Cambiar persona",
517
+ "ag.pc.going": "Cambiando…",
518
+ "ag.pc.current": "actual",
519
+ "ag.pc.busy":
520
+ "Está trabajando. ¿Cambiar después de este turno o detenerlo ahora?",
521
+ "ag.pc.after": "Después de este turno",
522
+ "ag.pc.now": "Detenerlo ahora",
523
+ "ag.pc.fresh":
524
+ "{id} ahora es {p}: aún no hay nada que reanudar; se inició de nuevo (mismo id de conversación, carpeta y alcance)",
525
+ "ag.freshLine":
526
+ "Aún no hay nada que reanudar; se inició de nuevo (mismo id de conversación, carpeta y alcance)",
527
+ "ag.pc.done": "{id} ahora es {p}",
528
+ "ag.pc.pending": "{id} pasará a ser {p} cuando termine este turno",
529
+ "ag.more": "Más para {id}",
492
530
  "ag.bring.go": "Traer",
493
531
  "ag.bring.going": "Trayendo…",
494
532
  "ag.bring.done": "Traída como {id}",
@@ -637,7 +675,11 @@
637
675
  "Su token fue revocado (web logout --all): detenlo y reanúdalo, o inícialo de nuevo, para darle uno nuevo",
638
676
  "ag.ended":
639
677
  "Esta sesión ha terminado. Su registro se queda hasta que se reanude o se elimine.",
678
+ "ag.switched": "Cambiado a {p}",
640
679
  "ag.exitedLine": "[la sesión terminó{code}]",
680
+ "ag.refusedUnknown": "el servidor rechazó la terminal",
681
+ "ag.refusedStatus": "el servidor rechazó la terminal ({status})",
682
+ "ag.goWs": "Abrir {ws} en Espacios de trabajo",
641
683
  "ag.lost": "[desconectado: {why}]",
642
684
  "ag.reconnect": "Reconectar",
643
685
  "ag.noBackend":
@@ -50,6 +50,8 @@
50
50
  "b.down": "{n} 件停止",
51
51
  "act.pin": "ピン留め",
52
52
  "act.unpin": "ピン留めを外す",
53
+ "act.visit": "開く",
54
+ "act.visitX": "{s} を開く(新しいタブ)",
53
55
  "act.pinX": "{s} をピン留め",
54
56
  "act.unpinX": "{s} のピン留めを外す",
55
57
  "toast.pinned": "{s} をピン留めしました",
@@ -101,6 +103,8 @@
101
103
  "kind.app": "アプリ",
102
104
  "kind.action": "操作",
103
105
  "kind.setting": "設定",
106
+ "kind.workspace": "ワークスペース",
107
+ "pal.goWs": "ワークスペース {ws} へ移動",
104
108
  "pal.repos": "{n} 個のリポジトリ",
105
109
  "pal.restart": "{s} を再起動",
106
110
  "pal.results": "結果",
@@ -242,12 +246,18 @@
242
246
  "err.signin.title": "{name} にサインイン",
243
247
  "err.signin.where":
244
248
  "別のコンピューターから {name} を開いています。{name} は {host} で動いているため、あなた本人であることを確認する必要があります。",
245
- "err.signin.githubNote":
246
- "あなたの GitHub アカウントを使います。この {name} のオーナーが自分のアカウントをリンクしている必要があります。",
247
- "err.signin.githubNoOwner":
248
- "GitHub でのサインインにはオーナーが必要ですが、この {name} にはまだいません。{host} で(このコンピューターではありません)次を実行してください:",
249
+ "err.signin.ghStep1":
250
+ "{host} で {cmd} を実行するか、そちらで 設定 › アクセス を使います。",
251
+ "err.signin.ghStep2":
252
+ "コードが表示されます。github.com/login/device で入力して承認します。",
253
+ "err.signin.ghStep3":
254
+ "ここに戻り、同じ GitHub アカウントで「GitHub でサインイン」を押します。",
255
+ "err.signin.ghOwner": "オーナー: @{login}",
256
+ "err.signin.ghOnly":
257
+ "この方法でサインインできるのはオーナーの GitHub アカウントだけです。それ以外の人は「このコンピューターを承認」を使ってください。",
258
+ "err.signin.ghLearn": "詳しく見る",
249
259
  "err.signin.githubRefused":
250
- "@{login} はこの {name} のオーナーではありません。オーナーに追加を依頼するか、「このコンピューターを承認」を使ってください。",
260
+ "@{login} はこの {name} のオーナーではありません。GitHub でサインインを使えるのはオーナーのアカウントだけです。代わりに「このコンピューターを承認」を使ってください。",
251
261
  "err.signin.approveOr": "または、このコンピューターを承認",
252
262
  "err.signin.approve": "このコンピューターを承認",
253
263
  "err.signin.step1":
@@ -473,6 +483,34 @@
473
483
  "ag.bring.agent": "エージェント {id}",
474
484
  "ag.bring.note":
475
485
  "実行していたフォルダーで続けます。ここからは Den のツールとガードが適用されます。ペインを閉じても実行は続きます。先にほかのターミナルで閉じてください。Den が開いていると判断できるのは、直近 1 分以内に書き込まれた会話だけです。",
486
+ "ag.keep.model": "会話の {m} をそのまま使います。",
487
+ "ag.keep.modelNew": "これからは {m} で動きます。",
488
+ "ag.keep.from":
489
+ "これからは {p} として働きます: その役割、権限({mode})、Den のツールとガード。履歴はそのまま残ります。",
490
+ "ag.keep.lost":
491
+ "使えなくなるもの: {s}。Den はエージェントを Den のツールだけで動かします。",
492
+ "ag.keep.opt": "{m} のまま",
493
+ "ag.keep.optNone": "今のモデルのまま",
494
+ "ag.keep.advNote":
495
+ "ここで別のモデルを選ばない限り、今のモデルのままです。権限はペルソナから来ます。",
496
+ "ag.pc.item": "ペルソナを変更…",
497
+ "ag.pc.title": "{id} のペルソナを変更",
498
+ "ag.pc.note":
499
+ "Den はいったん停止し、同じ会話を新しいペルソナで再開します。ID、名前、フォルダー、タブはそのままです。",
500
+ "ag.pc.go": "ペルソナを変更",
501
+ "ag.pc.going": "変更中…",
502
+ "ag.pc.current": "現在",
503
+ "ag.pc.busy":
504
+ "作業中です。このターンの後に変更しますか、それとも今すぐ停止しますか?",
505
+ "ag.pc.after": "このターンの後",
506
+ "ag.pc.now": "今すぐ停止",
507
+ "ag.pc.fresh":
508
+ "{id} は {p} になりました。まだ再開するものがないため、新しく開始しました(会話 ID、フォルダー、範囲は同じ)",
509
+ "ag.freshLine":
510
+ "まだ再開するものがないため、新しく開始しました(会話 ID、フォルダー、範囲は同じ)",
511
+ "ag.pc.done": "{id} は {p} になりました",
512
+ "ag.pc.pending": "このターンが終わると {id} は {p} になります",
513
+ "ag.more": "{id} のその他の操作",
476
514
  "ag.bring.go": "取り込む",
477
515
  "ag.bring.going": "取り込み中…",
478
516
  "ag.bring.done": "{id} として取り込みました",
@@ -621,7 +659,11 @@
621
659
  "ag.revokedTip":
622
660
  "トークンが失効しました(web logout --all)。新しいトークンを渡すには停止して再開するか、もう一度開始してください",
623
661
  "ag.ended": "このセッションは終了しました。記録は再開か削除まで残ります。",
662
+ "ag.switched": "{p} に切り替えました",
624
663
  "ag.exitedLine": "[セッションが終了しました{code}]",
664
+ "ag.refusedUnknown": "サーバーがターミナルを拒否しました",
665
+ "ag.refusedStatus": "サーバーがターミナルを拒否しました({status})",
666
+ "ag.goWs": "ワークスペースで {ws} を開く",
625
667
  "ag.lost": "[切断: {why}]",
626
668
  "ag.reconnect": "再接続",
627
669
  "ag.noBackend":
@@ -290,6 +290,7 @@
290
290
  }
291
291
  .module-picker-dropdown {
292
292
  display: none;
293
+ padding: 0;
293
294
  position: absolute;
294
295
  top: calc(100% + 4px);
295
296
  left: 0;
@@ -1568,6 +1569,11 @@
1568
1569
  e.stopPropagation();
1569
1570
  const open = pickerDropdown.classList.toggle("open");
1570
1571
  pickerBtn.classList.toggle("open", open);
1572
+ // 0.4.17: in the top layer, so the output pane can't cover it.
1573
+ if (open)
1574
+ window.denShell?.topLayer?.show(pickerDropdown, pickerBtn, {
1575
+ gap: 4,
1576
+ });
1571
1577
  });
1572
1578
  document.addEventListener("click", () => {
1573
1579
  pickerDropdown.classList.remove("open");
@@ -1168,6 +1168,11 @@
1168
1168
  }
1169
1169
  .tablewrap {
1170
1170
  overflow-x: auto;
1171
+ /* 0.4.17: in a short window (under ~620 px tall) the column's other
1172
+ parts squeezed it to nothing; it keeps room for rows and scrolls. */
1173
+ min-height: 200px;
1174
+ flex-shrink: 0;
1175
+ overflow-y: auto;
1171
1176
  border: 1px solid var(--border);
1172
1177
  border-radius: var(--radius);
1173
1178
  background: var(--surface);
@@ -1285,6 +1290,9 @@
1285
1290
  .tbl-health.ok {
1286
1291
  color: var(--ok);
1287
1292
  }
1293
+ .tbl-up:not(:empty) {
1294
+ margin-left: 8px;
1295
+ }
1288
1296
  .tbl-muted {
1289
1297
  color: var(--muted);
1290
1298
  font-size: 12.5px;
@@ -1330,6 +1338,46 @@
1330
1338
  .tbl-acts .tbl-more-sp {
1331
1339
  display: inline-block;
1332
1340
  }
1341
+ /* Visit ↗ (0.4.17): before Start, so Start/Stop/Restart/Logs and ⋯
1342
+ still line up across rows whether a service has a URL or not. */
1343
+ .tbl-acts .tbl-visit {
1344
+ flex: none;
1345
+ padding-left: 6px;
1346
+ padding-right: 6px;
1347
+ text-decoration: none;
1348
+ }
1349
+ /* The table never scrolls sideways: in a window under 1600px wide
1350
+ (the workspace list beside it) Visit is its ↗; its name and title
1351
+ still say Visit. */
1352
+ @media (max-width: 1599px) {
1353
+ .tbl-visit .tbl-visit-w {
1354
+ display: none;
1355
+ }
1356
+ }
1357
+ table.svc a.repo-name {
1358
+ text-decoration: underline;
1359
+ text-decoration-color: var(--border-strong);
1360
+ text-underline-offset: 2px;
1361
+ }
1362
+ table.svc a.repo-name:hover {
1363
+ text-decoration-color: currentColor;
1364
+ }
1365
+ /* The favourite star (0.4.17): the shell's pins, as on Services. */
1366
+ .tbl-star {
1367
+ background: none;
1368
+ border: 1px solid transparent;
1369
+ border-radius: var(--radius-button);
1370
+ color: var(--faint);
1371
+ cursor: pointer;
1372
+ font-size: 13px;
1373
+ line-height: 1.2;
1374
+ padding: 0 2px;
1375
+ }
1376
+ .tbl-star:hover,
1377
+ .tbl-star[aria-pressed="true"] {
1378
+ color: var(--accent-text);
1379
+ background: var(--sunken);
1380
+ }
1333
1381
  .tbl-btn:disabled {
1334
1382
  opacity: 0.45;
1335
1383
  cursor: not-allowed;
@@ -1769,7 +1817,11 @@
1769
1817
  .querySelectorAll(".ws-menu-dropdown.open")
1770
1818
  .forEach((d) => d.classList.remove("open"));
1771
1819
  const menu = document.getElementById(menuBtn.dataset.menuId);
1772
- menu?.classList.toggle("open");
1820
+ if (menu?.classList.toggle("open"))
1821
+ window.denShell?.topLayer?.show(menu, menuBtn, {
1822
+ align: "right",
1823
+ gap: 2,
1824
+ });
1773
1825
  return;
1774
1826
  }
1775
1827
  // ↗ open in editor
@@ -1858,8 +1910,16 @@
1858
1910
  if (d.id !== btn.dataset.menuId) d.style.display = "none";
1859
1911
  });
1860
1912
  const menu = document.getElementById(btn.dataset.menuId);
1861
- if (menu)
1862
- menu.style.display = menu.style.display === "none" ? "" : "none";
1913
+ if (menu) {
1914
+ const show = menu.style.display === "none";
1915
+ menu.style.display = show ? "" : "none";
1916
+ // 0.4.17: in the top layer, so no row or card clips it.
1917
+ if (show)
1918
+ window.denShell?.topLayer?.show(menu, btn, {
1919
+ align: "right",
1920
+ gap: 3,
1921
+ });
1922
+ }
1863
1923
  e.stopPropagation();
1864
1924
  return;
1865
1925
  }
@@ -2102,7 +2162,7 @@
2102
2162
  <th scope="col">Health</th>
2103
2163
  <th scope="col">Branch</th>
2104
2164
  <th scope="col">Mode</th>
2105
- <th scope="col">Last run</th>
2165
+ <th scope="col">Last test run</th>
2106
2166
  <th scope="col"><span class="tbl-sr">Actions</span></th>
2107
2167
  </tr></thead>
2108
2168
  </table>
@@ -2272,22 +2332,60 @@
2272
2332
  return [{ key: "", title: null, rows }];
2273
2333
  }
2274
2334
 
2275
- /** The last run (the runs dock's) of a repo's tab: "✓ 12:03", "running…", "—". */
2335
+ // 0.4.17: each repo's last test run, from the server's run history
2336
+ // (every browser's runs, kept across restarts), and when its
2337
+ // service started (the daemons list), shown apart from it.
2338
+ let _lastRuns = {};
2339
+ let _upSince = new Map();
2340
+ async function loadLastRuns() {
2341
+ const [last, daemons] = await Promise.all([
2342
+ fetch("/api/runs/last")
2343
+ .then((r) => (r.ok ? r.json() : null))
2344
+ .catch(() => null),
2345
+ fetch("/api/service/daemons")
2346
+ .then((r) => (r.ok ? r.json() : []))
2347
+ .catch(() => []),
2348
+ ]);
2349
+ _lastRuns = last?.repos ?? {};
2350
+ _upSince = new Map();
2351
+ for (const d of Array.isArray(daemons) ? daemons : []) {
2352
+ const at = Date.parse(d.startedAt);
2353
+ if (!d.repoName || !Number.isFinite(at)) continue;
2354
+ const cur = _upSince.get(d.repoName);
2355
+ if (cur === undefined || at < cur) _upSince.set(d.repoName, at);
2356
+ }
2357
+ paintLastRuns();
2358
+ }
2359
+
2360
+ /** A repo's last test run: "✓ 12:03", "✗ failed 12:03", "running…", "—". */
2276
2361
  function lastRunText(repo) {
2277
- const list = runs()?.list?.() ?? [];
2278
- const mine = list.filter(
2279
- (r) => r.id === repo || r.id.split("/")[0] === repo,
2280
- );
2281
- const r = mine.sort((a, b) => (b.ranAt ?? 0) - (a.ranAt ?? 0))[0];
2282
- if (!r || (!r.ranAt && !r.running)) return { text: "—", cls: "" };
2283
- if (r.running) return { text: "running…", cls: "" };
2362
+ const r = _lastRuns[repo];
2363
+ if (!r) return { text: "—", cls: "", title: "" };
2364
+ const title = [r.pillar, r.env].filter(Boolean).join(" · ");
2365
+ if (r.running) return { text: "running…", cls: "", title };
2284
2366
  const t = new Intl.DateTimeFormat(undefined, {
2285
2367
  hour: "2-digit",
2286
2368
  minute: "2-digit",
2287
- }).format(new Date(r.endedAt ?? r.ranAt));
2288
- return r.error
2289
- ? { text: `✗ failed ${t}`, cls: "bad" }
2290
- : { text: `✓ ${t}`, cls: "ok" };
2369
+ }).format(new Date(r.endedAt ?? r.startedAt));
2370
+ return r.exitCode === 0
2371
+ ? { text: `✓ ${t}`, cls: "ok", title }
2372
+ : { text: `✗ failed ${t}`, cls: "bad", title };
2373
+ }
2374
+
2375
+ /** How long a repo's service has run: "up 2h", or "" when it doesn't. */
2376
+ function upText(repo) {
2377
+ const at = _upSince.get(repo);
2378
+ if (at === undefined) return "";
2379
+ const s = Math.max(0, Math.round((Date.now() - at) / 1000));
2380
+ const d =
2381
+ s < 60
2382
+ ? `${s}s`
2383
+ : s < 3600
2384
+ ? `${Math.floor(s / 60)}m`
2385
+ : s < 86400
2386
+ ? `${Math.floor(s / 3600)}h`
2387
+ : `${Math.floor(s / 86400)}d`;
2388
+ return `up ${d}`;
2291
2389
  }
2292
2390
 
2293
2391
  /** A health cell: the dot and the rollup, or the failing check. */
@@ -2335,6 +2433,47 @@
2335
2433
  );
2336
2434
  }
2337
2435
 
2436
+ /** A service's URL to visit (its edge URL if routed, else its local one), or null. */
2437
+ function visitUrl(svc) {
2438
+ const u = svc?.open;
2439
+ return typeof u === "string" && /^https?:\/\//.test(u) ? u : null;
2440
+ }
2441
+ /** The shell's words (its language), else English. */
2442
+ function shT(key, vars, en) {
2443
+ const v = window.denShell?.t?.(key, vars);
2444
+ return v && v !== key ? v : en;
2445
+ }
2446
+ /** Visit ↗ (0.4.17): opens the service in a new tab; nothing without a URL. */
2447
+ function visitBtn(svc, name) {
2448
+ const url = visitUrl(svc);
2449
+ if (!url) return "";
2450
+ const label = shT(
2451
+ "act.visitX",
2452
+ { s: name },
2453
+ `Visit ${name} (opens a new tab)`,
2454
+ );
2455
+ return `<a class="den-btn sm tbl-btn tbl-visit" href="${escHtml(url)}" target="_blank" rel="noopener" aria-label="${escHtml(label)}" title="${escHtml(url)}" data-k="visit:${escHtml(name)}"><span class="tbl-visit-w">${escHtml(shT("act.visit", {}, "Visit"))} </span>↗</a>`;
2456
+ }
2457
+ /** A row's name: a link to visit when the service has a URL. */
2458
+ function nameLink(svc, text) {
2459
+ const url = visitUrl(svc);
2460
+ return url
2461
+ ? `<a class="repo-name" href="${escHtml(url)}" target="_blank" rel="noopener" title="${escHtml(shT("act.visitX", { s: url }, `Visit ${url} (opens a new tab)`))}">${escHtml(text)}</a>`
2462
+ : `<span class="repo-name">${escHtml(text)}</span>`;
2463
+ }
2464
+ /** The favourite star (0.4.17): the shell's pins, the same store as Services. */
2465
+ function starBtn(id) {
2466
+ const sh = window.denShell;
2467
+ if (!sh?.isPinned) return "";
2468
+ const on = sh.isPinned(id);
2469
+ const label = shT(
2470
+ on ? "act.unpinX" : "act.pinX",
2471
+ { s: id },
2472
+ `${on ? "Unpin" : "Pin"} ${id}`,
2473
+ );
2474
+ return `<button type="button" class="tbl-star" data-pin-toggle="${escHtml(id)}" aria-pressed="${on ? "true" : "false"}" aria-label="${escHtml(label)}" title="${escHtml(label)}" data-k="star:${escHtml(id)}">${on ? "★" : "☆"}</button>`;
2475
+ }
2476
+
2338
2477
  /** Action buttons; each one's name says what and on which repo or service. */
2339
2478
  function actBtns(id, o) {
2340
2479
  const b = (act, label, name, extra = "") =>
@@ -2407,12 +2546,12 @@
2407
2546
  ? `<span class="tbl-mode"><span class="badge ${escHtml(mode)}">${escHtml(mode)}</span><button type="button" class="tbl-switch" data-action="tbl-mode" data-repo="${escHtml(repo)}" data-mode="${switchTo}" aria-label="${escHtml(repo)}: use ${mode === "managed" ? "local" : "managed"}" data-k="mode:${escHtml(repo)}">use ${mode === "managed" ? "local" : "managed"}</button></span>`
2408
2547
  : "";
2409
2548
  const head = `<tr class="repo${r.problem ? " prob" : ""}" data-repo="${escHtml(repo)}">
2410
- <th scope="row">${exp}<span class="repo-name">${escHtml(repo)}</span>${badges}</th>
2411
- <td>${repoHealth(r)}</td>
2549
+ <th scope="row">${exp}${nameLink(primary, repo)} ${starBtn(repo)}${badges}</th>
2550
+ <td>${repoHealth(r)}<span class="tbl-muted tbl-up" data-up="${escHtml(repo)}">${escHtml(upText(repo))}</span></td>
2412
2551
  <td class="tbl-mono tbl-muted">${escHtml(primary?.branch ?? "")}</td>
2413
2552
  <td>${modeCell}</td>
2414
- <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}">${escHtml(run.text)}</td>
2415
- <td><div class="tbl-acts">${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2553
+ <td class="tbl-muted${run.cls ? " " + run.cls : ""}" data-last="${escHtml(repo)}" title="${escHtml(run.title)}">${escHtml(run.text)}</td>
2554
+ <td><div class="tbl-acts">${visitBtn(primary, repo)}${actBtns(repo, { up: !!startOf(repo).length, restart: !!startOf(repo).length })}${repoMenu(r)}</div></td>
2416
2555
  </tr>`;
2417
2556
  const fixes = r.fixes
2418
2557
  .map(
@@ -2434,10 +2573,10 @@
2434
2573
  s,
2435
2574
  i,
2436
2575
  ) => `<tr class="sub" id="${subIds[i]}" data-repo="${escHtml(repo)}" data-id="${escHtml(s.id)}"${shown(s) ? "" : " hidden"}>
2437
- <th scope="row"><span class="repo-name">${escHtml(s.name)}</span>${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2576
+ <th scope="row">${nameLink(s, s.name)} ${starBtn(s.id)}${s.entry ? ' <span class="badge">entry</span>' : ""}</th>
2438
2577
  <td>${!_lastHealthAt ? CHECKING() : healthCell(status(s), s.health.ok === false ? checkLineOf(s) : healthLine(s))}</td>
2439
2578
  <td></td><td></td><td></td>
2440
- <td><div class="tbl-acts">${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2579
+ <td><div class="tbl-acts">${visitBtn(s, s.id)}${actBtns(s.id, { up: s.commands.includes("up"), restart: s.commands.includes("up") || s.commands.includes("restart") })}<span class="tbl-more-sp" aria-hidden="true"></span></div></td>
2441
2580
  </tr>`,
2442
2581
  )
2443
2582
  .join("")
@@ -2516,13 +2655,17 @@
2516
2655
  if (keep)
2517
2656
  table.querySelector(`[data-k="${CSS.escape(keep)}"]`)?.focus();
2518
2657
  }
2519
- /** Only the Last run cells (the dock changed). */
2658
+ /** Only the Last test run and "up" cells (the history changed). */
2520
2659
  function paintLastRuns() {
2521
2660
  document.querySelectorAll("#repo-table [data-last]").forEach((td) => {
2522
2661
  const run = lastRunText(td.dataset.last);
2523
2662
  td.textContent = run.text;
2663
+ td.title = run.title;
2524
2664
  td.className = "tbl-muted" + (run.cls ? " " + run.cls : "");
2525
2665
  });
2666
+ document.querySelectorAll("#repo-table [data-up]").forEach((el) => {
2667
+ el.textContent = upText(el.dataset.up);
2668
+ });
2526
2669
  }
2527
2670
 
2528
2671
  /**
@@ -2581,6 +2724,14 @@
2581
2724
  setTableGroup(g.dataset.group);
2582
2725
  return;
2583
2726
  }
2727
+ const star = e.target.closest("#repo-table [data-pin-toggle]");
2728
+ if (star) {
2729
+ const sh = window.denShell;
2730
+ const id = star.dataset.pinToggle;
2731
+ if (sh?.isPinned(id)) sh.unpin(id);
2732
+ else sh?.pin(id);
2733
+ return;
2734
+ }
2584
2735
  const b = e.target.closest("[data-action^='tbl-']");
2585
2736
  if (!b || b.disabled) return;
2586
2737
  const a = b.dataset.action;
@@ -2641,8 +2792,20 @@
2641
2792
  const v = localStorage.getItem(LS_GROUP);
2642
2793
  if (["none", "app", "org"].includes(v)) _tblGroup = v;
2643
2794
  } catch {}
2644
- // The Last run column follows the dock.
2645
- window.denShell?.runs?.onChange?.(paintLastRuns);
2795
+ // The Last test run column: the server's history, read now, when a
2796
+ // run here changes, and every 30 s for runs from elsewhere.
2797
+ void loadLastRuns();
2798
+ // The dock changes on every line of a run: read at most every 2 s.
2799
+ let _lastRunsSoon = null;
2800
+ window.denShell?.runs?.onChange?.(() => {
2801
+ _lastRunsSoon ??= setTimeout(() => {
2802
+ _lastRunsSoon = null;
2803
+ void loadLastRuns();
2804
+ }, 2_000);
2805
+ });
2806
+ setInterval(() => {
2807
+ if (!document.hidden) void loadLastRuns();
2808
+ }, 30_000);
2646
2809
 
2647
2810
  // ── Tab management (one persistent tab per service address) ───────────────
2648
2811
  //
@@ -79,7 +79,7 @@ export const ACTIONS = [
79
79
  },
80
80
  {
81
81
  id: "open",
82
- label: "Open ↗",
82
+ label: "Visit ↗",
83
83
  for: ["service"],
84
84
  tier: "common",
85
85
  when: (s) => !!(s.open || s.target?.url),
@@ -181,6 +181,17 @@ dialog.pe-dlg::backdrop {
181
181
  .helpl.warn {
182
182
  color: var(--warn);
183
183
  }
184
+ /* AG-17.1c, AG-18: what a persona changes, under the list. */
185
+ .den-keep {
186
+ margin: 8px 0 0;
187
+ padding-left: 18px;
188
+ font-size: 12px;
189
+ line-height: 1.45;
190
+ color: var(--muted);
191
+ }
192
+ .den-keep li + li {
193
+ margin-top: 2px;
194
+ }
184
195
  .faint {
185
196
  color: var(--faint);
186
197
  }
@@ -134,6 +134,10 @@
134
134
  font-size: 11px;
135
135
  color: var(--muted);
136
136
  }
137
+ .view-note {
138
+ color: var(--muted);
139
+ font-size: 12px;
140
+ }
137
141
  #status-text {
138
142
  font-size: 11px;
139
143
  color: var(--muted);
@@ -678,6 +682,7 @@
678
682
  ⟳ Refresh
679
683
  </button>
680
684
  <span id="status-text">idle</span>
685
+ <span id="view-note"></span>
681
686
  <div class="h-spacer" style="flex: 1"></div>
682
687
  <button class="btn" id="waves-btn" title="Edit wave assignment">
683
688
  Waves…
@@ -735,6 +740,7 @@
735
740
  refreshHealth,
736
741
  initWorkspacePill,
737
742
  } from "/header.js";
743
+ import { viewNoteHtml, viewRepos, viewScope } from "/view-scope.js";
738
744
 
739
745
  // The header's dots report LOCAL service health here: #den-shell pins
740
746
  // data-health-env="local" (shell.js). The env picker is the shell's
@@ -819,6 +825,15 @@
819
825
  .replace(/>/g, "&gt;")
820
826
  .replace(/"/g, "&quot;");
821
827
 
828
+ // 0.4.17: the repos follow the top bar's View (Org and Workspace),
829
+ // as on Environments: no picker of this page's own (view-scope.js).
830
+ let viewIn = null;
831
+ const viewReady = viewScope().then(async (v) => {
832
+ const note = document.getElementById("view-note");
833
+ if (note) note.innerHTML = viewNoteHtml(v);
834
+ viewIn = await viewRepos(v);
835
+ });
836
+
822
837
  // ── Init ──────────────────────────────────────────────────────────────────
823
838
  async function init() {
824
839
  try {
@@ -909,7 +924,10 @@
909
924
  const data = await fetch(
910
925
  `/api/release/status?env=${encodeURIComponent(state.env)}&refresh=${refresh ? 1 : 0}`,
911
926
  ).then((r) => r.json());
912
- state.rows = data.rows || [];
927
+ await viewReady;
928
+ state.rows = (data.rows || []).filter(
929
+ (r) => !viewIn || viewIn.has(r.repo),
930
+ );
913
931
  setStatus(
914
932
  data.error
915
933
  ? `error: ${data.error}`