@songsid/agend 2.2.0-alpha.1 → 2.2.0-alpha.2

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 (150) hide show
  1. package/README.md +2 -0
  2. package/README.zh-TW.md +2 -0
  3. package/dist/agent-cli.js +6 -1
  4. package/dist/agent-cli.js.map +1 -1
  5. package/dist/agent-endpoint.js +68 -12
  6. package/dist/agent-endpoint.js.map +1 -1
  7. package/dist/agent-token-header.d.ts +13 -0
  8. package/dist/agent-token-header.js +16 -0
  9. package/dist/agent-token-header.js.map +1 -0
  10. package/dist/backend/antigravity.js +4 -3
  11. package/dist/backend/antigravity.js.map +1 -1
  12. package/dist/backend/grok.js +1 -1
  13. package/dist/backend/grok.js.map +1 -1
  14. package/dist/backend/kiro.d.ts +7 -0
  15. package/dist/backend/kiro.js +82 -33
  16. package/dist/backend/kiro.js.map +1 -1
  17. package/dist/backend/muse.js +1 -1
  18. package/dist/backend/muse.js.map +1 -1
  19. package/dist/backend/types.d.ts +6 -0
  20. package/dist/backend/types.js.map +1 -1
  21. package/dist/cache-analysis.d.ts +92 -0
  22. package/dist/cache-analysis.js +147 -0
  23. package/dist/cache-analysis.js.map +1 -0
  24. package/dist/cache-ledger.d.ts +108 -0
  25. package/dist/cache-ledger.js +230 -0
  26. package/dist/cache-ledger.js.map +1 -0
  27. package/dist/cache-prices.d.ts +32 -0
  28. package/dist/cache-prices.js +73 -0
  29. package/dist/cache-prices.js.map +1 -0
  30. package/dist/cache-scan.d.ts +39 -0
  31. package/dist/cache-scan.js +115 -0
  32. package/dist/cache-scan.js.map +1 -0
  33. package/dist/cache-service.d.ts +115 -0
  34. package/dist/cache-service.js +417 -0
  35. package/dist/cache-service.js.map +1 -0
  36. package/dist/cli-entry.d.ts +19 -0
  37. package/dist/cli-entry.js +36 -0
  38. package/dist/cli-entry.js.map +1 -0
  39. package/dist/cli.js +251 -24
  40. package/dist/cli.js.map +1 -1
  41. package/dist/daemon.d.ts +6 -0
  42. package/dist/daemon.js +90 -37
  43. package/dist/daemon.js.map +1 -1
  44. package/dist/event-log.d.ts +4 -1
  45. package/dist/event-log.js +6 -3
  46. package/dist/event-log.js.map +1 -1
  47. package/dist/fleet-control-audit.d.ts +5 -0
  48. package/dist/fleet-control-audit.js +7 -0
  49. package/dist/fleet-control-audit.js.map +1 -1
  50. package/dist/fleet-level-config.js +22 -0
  51. package/dist/fleet-level-config.js.map +1 -1
  52. package/dist/fleet-manager.d.ts +46 -0
  53. package/dist/fleet-manager.js +287 -40
  54. package/dist/fleet-manager.js.map +1 -1
  55. package/dist/full-restart.js +2 -3
  56. package/dist/full-restart.js.map +1 -1
  57. package/dist/general-knowledge/skills/persona-emoji/SKILL.md +3 -1
  58. package/dist/install-lock.d.ts +49 -0
  59. package/dist/install-lock.js +200 -0
  60. package/dist/install-lock.js.map +1 -0
  61. package/dist/instance-config-impact.js +3 -2
  62. package/dist/instance-config-impact.js.map +1 -1
  63. package/dist/locale.js +14 -0
  64. package/dist/locale.js.map +1 -1
  65. package/dist/outbound-schemas.js +1 -1
  66. package/dist/outbound-schemas.js.map +1 -1
  67. package/dist/package-preimage.d.ts +26 -0
  68. package/dist/package-preimage.js +96 -0
  69. package/dist/package-preimage.js.map +1 -0
  70. package/dist/public-web-gateway.js +2 -2
  71. package/dist/public-web-gateway.js.map +1 -1
  72. package/dist/quickstart.js +10 -4
  73. package/dist/quickstart.js.map +1 -1
  74. package/dist/restart-guard.d.ts +52 -0
  75. package/dist/restart-guard.js +109 -0
  76. package/dist/restart-guard.js.map +1 -0
  77. package/dist/scheduler/db.js +6 -2
  78. package/dist/scheduler/db.js.map +1 -1
  79. package/dist/service-activation.d.ts +47 -5
  80. package/dist/service-activation.js +156 -51
  81. package/dist/service-activation.js.map +1 -1
  82. package/dist/service-installer.d.ts +56 -1
  83. package/dist/service-installer.js +134 -12
  84. package/dist/service-installer.js.map +1 -1
  85. package/dist/service-plan.d.ts +65 -0
  86. package/dist/service-plan.js +108 -0
  87. package/dist/service-plan.js.map +1 -0
  88. package/dist/settings-api.js +46 -5
  89. package/dist/settings-api.js.map +1 -1
  90. package/dist/settings-change.js +34 -6
  91. package/dist/settings-change.js.map +1 -1
  92. package/dist/setup-host.js +2 -3
  93. package/dist/setup-host.js.map +1 -1
  94. package/dist/setup-wizard.js +2 -1
  95. package/dist/setup-wizard.js.map +1 -1
  96. package/dist/sqlite-open-errors.d.ts +12 -0
  97. package/dist/sqlite-open-errors.js +13 -0
  98. package/dist/sqlite-open-errors.js.map +1 -0
  99. package/dist/tmux-control.d.ts +22 -0
  100. package/dist/tmux-control.js +89 -5
  101. package/dist/tmux-control.js.map +1 -1
  102. package/dist/topic-commands.js +14 -5
  103. package/dist/topic-commands.js.map +1 -1
  104. package/dist/ui/chat-commands.js +43 -0
  105. package/dist/ui/chat-render.js +2 -2
  106. package/dist/ui/chat-store.js +30 -1
  107. package/dist/ui/chat-strings.js +22 -2
  108. package/dist/ui/chat-thread.js +47 -10
  109. package/dist/ui/fleet-cache.js +168 -0
  110. package/dist/ui/panel-chat.js +104 -18
  111. package/dist/ui/panel-fleet.js +26 -12
  112. package/dist/ui/panel-settings.js +12 -8
  113. package/dist/ui/preview-panel.js +202 -0
  114. package/dist/ui/preview.js +7 -3
  115. package/dist/ui/settings-strings.js +4 -2
  116. package/dist/ui/shared/app-route.js +1 -1
  117. package/dist/ui/shared/app-shell.js +6 -4
  118. package/dist/ui/shared/app.css +147 -3
  119. package/dist/ui/shared/signin.js +13 -1
  120. package/dist/ui/shared/ui-icons.js +2 -0
  121. package/dist/ui/signin.html +2 -1
  122. package/dist/update-dispatch.d.ts +28 -0
  123. package/dist/update-dispatch.js +73 -0
  124. package/dist/update-dispatch.js.map +1 -0
  125. package/dist/update-install.d.ts +65 -7
  126. package/dist/update-install.js +126 -26
  127. package/dist/update-install.js.map +1 -1
  128. package/dist/web-api.d.ts +14 -0
  129. package/dist/web-api.js +65 -0
  130. package/dist/web-api.js.map +1 -1
  131. package/dist/web-commands.d.ts +77 -0
  132. package/dist/web-commands.js +81 -0
  133. package/dist/web-commands.js.map +1 -0
  134. package/dist/web-shell-routes.d.ts +1 -1
  135. package/dist/web-shell-routes.js +1 -1
  136. package/dist/web-shell-routes.js.map +1 -1
  137. package/launcher/agend +110 -0
  138. package/launcher/agend-agent +110 -0
  139. package/launcher/agend-agent.cjs +4 -0
  140. package/launcher/agend.cjs +4 -0
  141. package/launcher/install-admission.cjs +105 -0
  142. package/launcher/launch.cjs +64 -0
  143. package/launcher/postinstall.cjs +114 -0
  144. package/launcher/run-in-process.cjs +13 -0
  145. package/launcher/runtime-platform.cjs +86 -0
  146. package/launcher/runtime-select.cjs +217 -0
  147. package/package.json +12 -4
  148. package/scripts/preinstall-guard.cjs +40 -21
  149. package/templates/launchd.plist.ejs +2 -2
  150. package/templates/systemd.service.ejs +1 -1
@@ -18,13 +18,14 @@ import { toast } from "/assets/ui-toast.js";
18
18
  import { confirmDialog } from "/assets/ui-confirm.js";
19
19
  import { confirmedWrite } from "./settings-confirm.js";
20
20
  import { OrgChart } from "./fleet-org.js";
21
+ import { CacheReport } from "./fleet-cache.js";
21
22
 
22
23
  register("fleet", {
23
24
  en: {
24
- title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", config: "Config",
25
+ title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
25
26
  newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
26
27
  noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
27
- noTeams: "No teams yet", noTeamsHint: "Group instances for coordinated work.", countTasks: "{0} tasks", countSchedules: "{0} schedules", countTeams: "{0} teams",
28
+ noTeams: "No teams yet", noTeamsHint: "Group instances for coordinated work.", countTasks: "{0} tasks", countSchedules: "{0} schedules", countTeams: "{0} teams", countTask: "1 task", countSchedule: "1 schedule", countTeam: "1 team",
28
29
  claim: "Claim", done: "Done", delete: "Delete", on: "on", off: "off", cancel: "Cancel", create: "Create", creating: "Creating…", save: "Save changes",
29
30
  taskTitle: "Title", taskDesc: "Description", priority: "Priority", assignee: "Assignee", unassigned: "Unassigned",
30
31
  pNormal: "Normal", pLow: "Low", pHigh: "High", pUrgent: "Urgent",
@@ -41,10 +42,10 @@ register("fleet", {
41
42
  fleetDefault: "Use the fleet default", notInstalled: "(not installed)", deprecated: "(deprecated)", instanceCreated: "Instance created",
42
43
  },
43
44
  "zh-TW": {
44
- title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", config: "設定",
45
+ title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
45
46
  newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
46
47
  noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
47
- noTeams: "還沒有 team", noTeamsHint: "把 instance 分組一起工作。", countTasks: "{0} 個 task", countSchedules: "{0} 個排程", countTeams: "{0} 個 team",
48
+ noTeams: "還沒有 team", noTeamsHint: "把 instance 分組一起工作。", countTasks: "{0} 個 task", countSchedules: "{0} 個排程", countTeams: "{0} 個 team", countTask: "1 個 task", countSchedule: "1 個排程", countTeam: "1 個 team",
48
49
  claim: "認領", done: "完成", delete: "刪除", on: "開", off: "關", cancel: "取消", create: "建立", creating: "建立中…", save: "儲存變更",
49
50
  taskTitle: "標題", taskDesc: "說明", priority: "優先順序", assignee: "負責人", unassigned: "未指派",
50
51
  pNormal: "一般", pLow: "低", pHigh: "高", pUrgent: "緊急",
@@ -62,7 +63,7 @@ register("fleet", {
62
63
  },
63
64
  });
64
65
 
65
- const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", config: "sliders" };
66
+ const ICONS = { tasks: "tasks", schedules: "clock", teams: "team", org: "org", cache: "chart", config: "sliders" };
66
67
  const BACKENDS = ["claude-code", "codex", "opencode", "kiro-cli", "antigravity", "grok", "muse"];
67
68
 
68
69
  async function api(method, path, body, lease) {
@@ -73,7 +74,7 @@ async function api(method, path, body, lease) {
73
74
  }
74
75
 
75
76
  /**
76
- * Load `path` under the lease: `null` while the first load runs, `{ error }` on failure. A refresh (a new `version`,
77
+ * Load `path` under the lease: `null` while the first load runs, `{ error }` on failure (no answer, or an HTTP error). A refresh (a new `version`,
77
78
  * after Claim or Create) keeps showing what is there until the new list arrives — nothing below it is torn down, so
78
79
  * an open dialog and its draft survive (#1425 review). Only the latest request's answer is kept.
79
80
  */
@@ -86,7 +87,10 @@ function useLoad(lease, path, version) {
86
87
  if (shownFor.current !== lease) { shownFor.current = lease; setState(null); }
87
88
  (async () => {
88
89
  try {
89
- const d = await api("GET", path, null, lease);
90
+ // A read the server refused (a 4xx/5xx with an { error } body) is a failure, not an empty list (alpha.2 sweep).
91
+ const r = await lease.fetch(path, { method: "GET", headers: { "Content-Type": "application/json" } });
92
+ if (!r.ok) throw new Error(`HTTP ${r.status}`);
93
+ const d = await r.json();
90
94
  if (lease.current() && mine === seq.current) setState({ data: d });
91
95
  } catch { if (lease.current() && mine === seq.current) setState({ error: true }); }
92
96
  })();
@@ -98,12 +102,12 @@ export function FleetPanel({ route, navKey }) {
98
102
  const lease = useLease(navKey);
99
103
  const tab = route.tab;
100
104
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
101
- const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, config: Config }[tab] || Tasks;
105
+ const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
102
106
  return html`<div class="panel p-fleet">
103
107
  <${PanelHeader} title=${t("fleet.title")} />
104
108
  <nav class="seg" aria-label=${t("fleet.title")}>${FLEET_TABS.map(k => html`<a key=${k} href=${fleetPath(k)} class=${`seg-item${k === tab ? " active" : ""}`}
105
109
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
106
- <div class="panel-body"><div class=${`col${tab === "org" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
110
+ <div class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
107
111
  </div>`;
108
112
  }
109
113
 
@@ -126,7 +130,7 @@ function Tasks({ lease }) {
126
130
  }
127
131
  return html`<${Loaded} state=${state}>${(d) => {
128
132
  const tasks = d.tasks || [];
129
- return html`<${ListHead} count=${t("fleet.countTasks", tasks.length)} label=${t("fleet.newTask")} action=${{ onClick: () => setCreating(true) }} />
133
+ return html`<${ListHead} count=${tasks.length === 1 ? t("fleet.countTask") : t("fleet.countTasks", tasks.length)} label=${t("fleet.newTask")} action=${{ onClick: () => setCreating(true) }} />
130
134
  ${tasks.length ? html`<ul class="rows">${tasks.map(x => html`<li key=${x.id} class="row-item">
131
135
  <span class=${`pill ${x.status}`}>${x.status}</span><span class="grow">${x.title}</span>
132
136
  ${x.assignee ? html`<a class="link" href=${chatPath(x.assignee)}>${x.assignee}</a>` : null}
@@ -150,7 +154,7 @@ function Schedules({ lease }) {
150
154
  }
151
155
  return html`<${Loaded} state=${state}>${(d) => {
152
156
  const list = d.schedules || [];
153
- return html`<${ListHead} count=${t("fleet.countSchedules", list.length)} label=${t("fleet.newSchedule")} action=${{ onClick: () => setCreating(true) }} />
157
+ return html`<${ListHead} count=${list.length === 1 ? t("fleet.countSchedule") : t("fleet.countSchedules", list.length)} label=${t("fleet.newSchedule")} action=${{ onClick: () => setCreating(true) }} />
154
158
  ${list.length ? html`<ul class="rows">${list.map(x => html`<li key=${x.id} class="row-item">
155
159
  <span class=${`pill ${x.enabled ? "open" : "done"}`}>${x.enabled ? t("fleet.on") : t("fleet.off")}</span>
156
160
  <span class="grow">${x.label || cronDesc(x.cron)}</span><span class="mono muted" title=${cronDesc(x.cron)}>${x.cron}</span>
@@ -174,7 +178,7 @@ function Teams({ lease }) {
174
178
  }
175
179
  return html`<${Loaded} state=${state}>${(d) => {
176
180
  const entries = Object.entries(d.teams || {});
177
- return html`<${ListHead} count=${t("fleet.countTeams", entries.length)} label=${t("fleet.newTeam")} action=${{ onClick: () => setCreating(true) }} />
181
+ return html`<${ListHead} count=${entries.length === 1 ? t("fleet.countTeam") : t("fleet.countTeams", entries.length)} label=${t("fleet.newTeam")} action=${{ onClick: () => setCreating(true) }} />
178
182
  ${entries.length ? html`<ul class="rows">${entries.map(([name, team]) => html`<li key=${name} class="row-item">
179
183
  <span class="grow strong">${name}</span><span class="muted">${(team.members || []).join(", ")}</span>
180
184
  <button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(name)}>${t("fleet.delete")}</button></li>`)}</ul>`
@@ -191,6 +195,16 @@ function Org({ lease }) {
191
195
  return html`<${Loaded} state=${state}>${(org) => html`<${OrgChart} org=${org} app=${app} />`}</${Loaded}>`;
192
196
  }
193
197
 
198
+ /** #1468: the prompt-cache analysis — one read per opening and per window chosen; Refresh reads again (fleet-cache.js). */
199
+ function Cache({ lease }) {
200
+ const [win, setWin] = useState("7d");
201
+ const [version, setVersion] = useState(0);
202
+ const state = useLoad(lease, `/ui/cache?window=${win}`, version);
203
+ return html`<${Loaded} state=${state}>${(report) => (report && Array.isArray(report.instances)
204
+ ? html`<${CacheReport} report=${report} win=${win} setWin=${setWin} refresh=${() => setVersion((v) => v + 1)} />`
205
+ : html`<${ErrorState} onRetry=${renavigate} />`)}</${Loaded}>`;
206
+ }
207
+
194
208
  function Secret({ id, value, onInput, label }) {
195
209
  const [shown, setShown] = useState(false);
196
210
  return html`<div class="field-row"><input id=${id} type=${shown ? "text" : "password"} value=${value} onInput=${onInput} autocomplete="off" />
@@ -12,7 +12,7 @@ import { html, useEffect, useMemo, useRef, useState } from "/assets/app-html.js"
12
12
  import { t } from "/assets/app-i18n.js";
13
13
  import { appStore, useStore } from "/assets/app-store.js";
14
14
  import { useLease } from "/assets/app-ctx.js";
15
- import { PanelHeader, setTitle } from "/assets/app-shell.js";
15
+ import { PanelHeader, PendingChanges, setTitle } from "/assets/app-shell.js";
16
16
  import { navigate, setLeaveGuard } from "/assets/app-nav.js";
17
17
  import { settingsPath, SETTINGS_SECTIONS } from "/assets/app-route.js";
18
18
  import { Dialog } from "/assets/ui-dialog.js";
@@ -66,7 +66,8 @@ async function loadAll(lease) {
66
66
  const connections = await read(lease, "/api/settings/connections");
67
67
  if (!lease.current()) return null;
68
68
  out.connections = connections.ok && Array.isArray(connections.body) ? connections.body : [];
69
- const secrets = await read(lease, "/api/settings/provider-secrets");
69
+ // Only where the fleet offers them (the schema says so): otherwise the route is a 404 and there is nothing to show.
70
+ const secrets = out.schema && out.schema.provider_secrets === false ? { ok: false } : await read(lease, "/api/settings/provider-secrets");
70
71
  if (!lease.current()) return null;
71
72
  out.providerOk = secrets.ok;
72
73
  out.providerSecrets = secrets.ok && Array.isArray(secrets.body) ? secrets.body : [];
@@ -218,6 +219,7 @@ export function SettingsPanel({ route, navKey }) {
218
219
  ${searchable ? html`<div class="s-toolbar"><label class="s-search"><${Icon} name="search" size=${14} /><span class="sr-only">${tn("searchAll")}</span>
219
220
  <input type="search" value=${search} placeholder=${tn("searchAll")} onInput=${(e) => setSearch(e.target.value)} /></label></div>` : null}
220
221
  ${op ? html`<${OperationCard} op=${op} schema=${data && data.schema} />` : null}
222
+ <${PendingChanges} inline=${true} />
221
223
  ${sectionBody}
222
224
  </div></div>
223
225
  ${staged.size ? html`<${PendingBar} staged=${staged} busy=${opBusy(op)} onApply=${apply} onDiscard=${discard} />` : null}
@@ -329,7 +331,7 @@ function Agents({ ctx, search, openDialog }) {
329
331
  setBusy((b) => new Map(b).set(name, action));
330
332
  try { await work(); } finally { setBusy((b) => { const n = new Map(b); n.delete(name); return n; }); }
331
333
  };
332
- const head = html`<div class="list-head"><span>${tn("countAgents", Object.keys(insts).length)}</span>
334
+ const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
333
335
  <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
334
336
  if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />`;
335
337
  if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
@@ -350,6 +352,7 @@ function Agents({ ctx, search, openDialog }) {
350
352
  <span class=${dotClass(st)} title=${tn(STATUS_KEY[st] || "stStopped")} aria-hidden="true"></span>
351
353
  <span class="s-name" title=${name}>${shortName(name, inst)}</span>
352
354
  <span class="s-meta" title=${sum.backend}>${sum.model}</span>
355
+ <span class="s-br" aria-hidden="true"></span>
353
356
  <span class="tag">${tn("effortTag", sum.effort)}</span>
354
357
  ${ch ? html`<span class=${`tag${ch.includes("persona") ? " persona" : ""}`}>${ch}</span>` : null}
355
358
  <span class="sr-only">${tn(STATUS_KEY[st] || "stStopped")}</span>
@@ -370,7 +373,7 @@ function Bots({ ctx, search, openDialog }) {
370
373
  const all = channelsOf(ctx.fleet);
371
374
  const needle = search.trim().toLowerCase();
372
375
  const rows = all.map((ch, i) => ({ ch, i })).filter(({ ch }) => !needle || [ch.id, ch.type, ch.group_id, ch.bot_token_env].some((v) => String(v ?? "").toLowerCase().includes(needle)));
373
- const head = html`<div class="list-head"><span>${tn("countBots", all.length)}</span>
376
+ const head = html`<div class="list-head"><span>${all.length === 1 ? tn("countBot") : tn("countBots", all.length)}</span>
374
377
  <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "newBot" })}><${Icon} name="plus" size=${16} />${tn("newBot")}</button></div>`;
375
378
  return html`${head}
376
379
  ${!rows.length ? html`<${Empty} icon="plug" title=${needle ? tn("noAgentMatch", search.trim()) : tn("noBots")} />` : html`<div class="s-list">${rows.map(({ ch, i }) => {
@@ -464,7 +467,7 @@ function Classic({ ctx, search, openDialog }) {
464
467
  .sort((a, b) => ((STATUS_ORDER[ctx.classicRuntime(a.instanceName).status] ?? 9) - (STATUS_ORDER[ctx.classicRuntime(b.instanceName).status] ?? 9))
465
468
  || String(a.name || "").localeCompare(String(b.name || "")));
466
469
  if (!list.length) return html`<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
467
- return html`<div class="list-head"><span>${tn("countRooms", rooms.length)}</span></div><div class="s-list">${list.map((c) => {
470
+ return html`<div class="list-head"><span>${rooms.length === 1 ? tn("countRoom") : tn("countRooms", rooms.length)}</span></div><div class="s-list">${list.map((c) => {
468
471
  const rt = ctx.classicRuntime(c.instanceName);
469
472
  const sum = effectiveSummary(ctx.live[c.instanceName] || {}, c, ctx.fleet.defaults || {}, (ctx.classic && ctx.classic.defaults) || {});
470
473
  const adapter = c.adapterId || "discord";
@@ -472,6 +475,7 @@ function Classic({ ctx, search, openDialog }) {
472
475
  <span class=${dotClass(rt.execution)} title=${tn(`exec_${rt.execution}`)} aria-hidden="true"></span>
473
476
  <span class="s-name" title=${c.instanceName || ""}>${shortName(c.name || c.instanceName || "?", { display_name: c.display_name })}</span>
474
477
  <span class="s-meta" title=${sum.backend}>${sum.model}</span>
478
+ <span class="s-br" aria-hidden="true"></span>
475
479
  <span class="tag">${tn("effortTag", sum.effort)}</span>
476
480
  <span class=${`tag${adapter !== "discord" ? " persona" : ""}`}>${adapter}</span>
477
481
  ${c.collab ? html`<span class="tag">collab</span>` : null}
@@ -666,10 +670,10 @@ function Developer({ ctx }) {
666
670
  <div class="seg-inline" role="group" aria-label=${tn("format")}>
667
671
  ${["yaml", "json"].map((k) => html`<button key=${k} type="button" class=${`btn btn-sm${fmt === k ? " on" : ""}`} aria-pressed=${fmt === k ? "true" : "false"} onClick=${() => setFmt(k)}>${k.toUpperCase()}</button>`)}</div>
668
672
  <button type="button" class="btn btn-sm" onClick=${() => setEditing(!editing)}>${editing ? tn("viewMode") : tn("editMode")}</button>
669
- <button type="button" class="btn btn-sm" onClick=${copy}><${Icon} name="copy" size=${14} />${tn("copy")}</button>
670
- <button type="button" class="btn btn-sm" onClick=${download}><${Icon} name="download" size=${14} />fleet.yaml</button>
673
+ <button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
674
+ <button type="button" class="btn btn-sm" title="fleet.yaml" onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">fleet.yaml</span></button>
671
675
  </div>
672
- ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
676
+ ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
673
677
  <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
674
678
  : html`<pre class="s-yaml">${text}</pre>`}
675
679
  ${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
@@ -0,0 +1,202 @@
1
+ // #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
2
+ // wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
3
+ // preview.js makes (mountPreview: the preview origin, sandbox allow-scripts only), started by AgendPreview.start like a
4
+ // card's, only told to fill the panel. It runs on a click, as a card's does:
5
+ // - "Open in panel" on an idle card opens the panel with its Preview button — the panel runs nothing by itself;
6
+ // - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
7
+ // the panel: a moved frame reloads anyway);
8
+ // - a newer version of the block in a later reply is offered, never swapped in, and offered with Preview to click.
9
+ // Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
10
+ import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
+ import { t } from "/assets/app-i18n.js";
12
+ import { Icon } from "/assets/ui-icons.js";
13
+ import "./chat-render.js";
14
+ import "./preview.js";
15
+
16
+ const R = () => globalThis.AgendChatRender;
17
+ const P = () => globalThis.AgendPreview;
18
+
19
+ // ── The panel's state, one per page (the chat shows one instance at a time) ──
20
+
21
+ /** open: { key, instance, msgKey, n, code, sender, ts } | null; run: start it on mount (a moved run). */
22
+ let state = { open: null, run: 0 };
23
+ const subs = new Set();
24
+ function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
25
+ export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
26
+
27
+ /** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
28
+ export const frameKey = (key) => `panel:${key}`;
29
+ /** Show `spec` in the panel; `run`: start it there (a click that moves a running preview). The block shown before stops. */
30
+ export function openPanel(spec, { run = false } = {}) {
31
+ if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
32
+ set({ open: { ...spec }, run: run ? state.run + 1 : 0 });
33
+ }
34
+ /** Close the panel: its frame stops first. */
35
+ export function closePanel() {
36
+ if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
37
+ if (state.open) set({ open: null, run: 0 });
38
+ }
39
+ /** The card key the panel shows, or null. */
40
+ export const shownKey = () => (state.open ? state.open.key : null);
41
+
42
+ /** The text of a block's <title>, or "" — what says two blocks are versions of one thing. */
43
+ export function htmlTitle(code) {
44
+ const m = /<title[^>]*>([\s\S]*?)<\/title>/i.exec(String(code || ""));
45
+ return m ? m[1].replace(/\s+/g, " ").trim() : "";
46
+ }
47
+
48
+ /**
49
+ * A newer version of the shown block: the last complete ```html block in an agent reply after the one it came from.
50
+ * When the shown block has a <title>, only a block with the same title counts (another page is not a new version);
51
+ * without one, the newest block does. `msgs` in order; `keyOf` makes a message's key.
52
+ */
53
+ export function newerVersion(open, msgs, keyOf) {
54
+ if (!open || !Array.isArray(msgs)) return null;
55
+ const at = msgs.findIndex(x => keyOf(x) === open.msgKey);
56
+ if (at < 0) return null;
57
+ const title = htmlTitle(open.code);
58
+ for (let i = msgs.length - 1; i > at; i--) {
59
+ const x = msgs[i];
60
+ if (x.role !== "agent") continue;
61
+ const fences = R().htmlFences(x.text);
62
+ for (let n = fences.length - 1; n >= 0; n--) {
63
+ const f = fences[n];
64
+ if (!f.terminated || (title && htmlTitle(f.code) !== title)) continue;
65
+ const msgKey = keyOf(x);
66
+ return { key: `${msgKey}:f${n}`, instance: open.instance, msgKey, n, code: f.code, sender: x.sender, ts: x.ts };
67
+ }
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // ── The panel's width: this device's choice (a convenience — the panel works without storage) ──
73
+
74
+ const WIDTH_KEY = "agend_preview_panel_w";
75
+ export const MIN_PANEL = 320, MIN_THREAD = 360, STEP = 32;
76
+ function storedWidth() { try { const v = Number(localStorage.getItem(WIDTH_KEY)); return Number.isFinite(v) && v > 0 ? v : null; } catch { return null; } }
77
+ function storeWidth(w) { try { localStorage.setItem(WIDTH_KEY, String(Math.round(w))); } catch { /* this page only */ } }
78
+ /** The width a panel may have in a split `total` px wide: at least MIN_PANEL, leaving the thread MIN_THREAD. */
79
+ export function clampWidth(w, total) {
80
+ const max = Math.max(MIN_PANEL, total - MIN_THREAD - 8);
81
+ return Math.round(Math.min(max, Math.max(MIN_PANEL, w)));
82
+ }
83
+
84
+ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "");
85
+
86
+ /**
87
+ * The panel, inside the chat's split (`split`: that element — the divider sets its width there, through CSSOM).
88
+ * props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
89
+ * reveal(msgKey) (the thread shows that message), download(code).
90
+ */
91
+ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download }) {
92
+ const [st, setSt] = useState(state);
93
+ const [view, setView] = useState("preview");
94
+ const [run, setRun] = useState({ state: "idle", reason: "" });
95
+ const [newer, setNewer] = useState(null);
96
+ const holder = useRef(null), divider = useRef(null), panel = useRef(null);
97
+ const open = st.open && st.open.instance === name ? st.open : null;
98
+
99
+ useEffect(() => panelStore.subscribe(setSt), []);
100
+ // This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
101
+ const [, choice] = useState(0);
102
+ useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
103
+ // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
104
+ useEffect(() => () => closePanel(), []);
105
+
106
+ const ui = { state: (s, reason) => setRun({ state: s === "starting" || s === "running" ? s : "idle", reason: reason || "" }) };
107
+ const start = () => {
108
+ if (!open || !holder.current) return;
109
+ setView("preview");
110
+ P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
111
+ };
112
+ const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
113
+ // A new block in the panel: what ran before is gone (openPanel stopped it); a moved run starts here.
114
+ useEffect(() => {
115
+ setRun({ state: "idle", reason: "" });
116
+ setView("preview");
117
+ if (open && st.run) start();
118
+ // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
119
+ if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
120
+ }, [open && open.key, st.run]);
121
+ // A later reply with a newer version of this block: offered, not swapped in.
122
+ useEffect(() => {
123
+ if (!open) { setNewer(null); return undefined; }
124
+ const check = () => setNewer(newerVersion(open, msgs() || [], keyOf));
125
+ check();
126
+ return subscribe((instance, kind) => { if (instance === name && kind === "msgs") check(); });
127
+ }, [open && open.key, name]);
128
+
129
+ // The divider: drag, or the arrow keys, Home and End; the width is this device's.
130
+ const widthNow = useRef(MIN_PANEL);
131
+ const applyWidth = (w, save) => {
132
+ const sp = split.current; if (!sp) return;
133
+ const total = sp.clientWidth || 0;
134
+ const v = clampWidth(w, total);
135
+ widthNow.current = v;
136
+ sp.style.setProperty("--pv-w", `${v}px`); // CSSOM: the page's CSP allows no style attribute
137
+ if (divider.current) { divider.current.setAttribute("aria-valuenow", String(v)); divider.current.setAttribute("aria-valuemax", String(clampWidth(Infinity, total))); }
138
+ if (save) storeWidth(v);
139
+ };
140
+ useEffect(() => { if (open) applyWidth(storedWidth() ?? (split.current && split.current.clientWidth || 0) / 2, false); }, [!!open]);
141
+ const width = () => widthNow.current;
142
+ const setWidth = (w) => applyWidth(w, true);
143
+ const onKey = (e) => {
144
+ const k = e.key;
145
+ if (k === "ArrowLeft") setWidth(width() + STEP);
146
+ else if (k === "ArrowRight") setWidth(width() - STEP);
147
+ else if (k === "Home") setWidth(Infinity);
148
+ else if (k === "End") setWidth(0);
149
+ else return;
150
+ e.preventDefault();
151
+ };
152
+ const onDown = (e) => {
153
+ const sp = split.current; if (!sp || e.button !== 0) return;
154
+ e.preventDefault();
155
+ const el = e.currentTarget;
156
+ try { el.setPointerCapture(e.pointerId); } catch { /* the move still works while over the divider */ }
157
+ sp.classList.add("resizing"); // frames take no pointer events meanwhile, or the drag would stop over one
158
+ const right = sp.getBoundingClientRect().right;
159
+ const move = (ev) => applyWidth(right - ev.clientX - 4, false);
160
+ const up = () => { sp.classList.remove("resizing"); storeWidth(widthNow.current); el.removeEventListener("pointermove", move); el.removeEventListener("pointerup", up); el.removeEventListener("pointercancel", up); };
161
+ el.addEventListener("pointermove", move);
162
+ el.addEventListener("pointerup", up);
163
+ el.addEventListener("pointercancel", up);
164
+ };
165
+ // Esc in the panel closes it (and is not the chat's "stop the reply").
166
+ const onPanelKey = (e) => { if (e.key === "Escape" && !e.isComposing) { e.preventDefault(); closePanel(); } };
167
+
168
+ if (!open) return null;
169
+ const going = run.state === "starting" || run.state === "running";
170
+ const a = P().availability();
171
+ const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : a.reason);
172
+ const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
173
+ return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
174
+ aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
175
+ <aside class="pv-panel" ref=${panel} aria-label=${t("chat.pvPanel")} onKeyDown=${onPanelKey}>
176
+ <div class="pv-panel-head">
177
+ <button type="button" class="icon-btn only-narrow pv-panel-close" title=${t("chat.pvBack")} aria-label=${t("chat.pvBack")} onClick=${closePanel}><${Icon} name="back" size=${18} /></button>
178
+ <div class="pv-panel-title"><strong>${t("chat.pvPanel")}</strong>
179
+ <button type="button" class="pv-source" title=${source} onClick=${() => { if (window.matchMedia && window.matchMedia("(max-width: 899px)").matches) closePanel(); reveal(open.msgKey); }}>${source}</button></div>
180
+ <button type="button" class="icon-btn only-wide pv-panel-close" title=${t("chat.pvClose")} aria-label=${t("chat.pvClose")} onClick=${closePanel}><${Icon} name="close" size=${18} /></button>
181
+ </div>
182
+ <div class="pv-panel-bar">
183
+ <div class="seg-inline" role="group" aria-label=${t("chat.pvPanel")}>
184
+ <button type="button" class=${`btn btn-sm${view === "preview" ? " on" : ""}`} aria-pressed=${view === "preview" ? "true" : "false"} onClick=${() => setView("preview")}><${Icon} name="eye" size=${14} />${t("chat.pvPage")}</button>
185
+ <button type="button" class=${`btn btn-sm${view === "code" ? " on" : ""}`} aria-pressed=${view === "code" ? "true" : "false"} onClick=${() => setView("code")}><${Icon} name="code" size=${14} />${t("chat.pvCode")}</button>
186
+ </div>
187
+ <span class="grow"></span>
188
+ ${going ? html`<button type="button" class="btn btn-sm pv-panel-reload" title=${t("chat.pvReload")} onClick=${() => { stop(); start(); }}><${Icon} name="restart" size=${14} /><span class="lbl">${t("chat.pvReload")}</span></button>
189
+ <button type="button" class="btn btn-sm pv-panel-stop" title=${t("chat.pvStop")} onClick=${stop}><${Icon} name="stop" size=${14} /><span class="lbl">${t("chat.pvStop")}</span></button>`
190
+ : html`<button type="button" class="btn btn-sm btn-primary pv-panel-run" title=${t("chat.pvPreview")} disabled=${!a.ok} onClick=${start}><${Icon} name="play" size=${14} /><span class="lbl">${t("chat.pvPreview")}</span></button>`}
191
+ <button type="button" class="btn btn-sm pv-panel-dl" title=${t("chat.pvDownload")} onClick=${() => download(open.code)}><${Icon} name="download" size=${14} /><span class="lbl">${t("chat.pvDownload")}</span></button>
192
+ </div>
193
+ ${newer && newer.key !== open.key ? html`<div class="pv-newer" role="status"><span>${t("chat.pvNewer", timeOf(newer.ts))}</span>
194
+ <button type="button" class="btn btn-sm pv-newer-show" onClick=${() => openPanel(newer)}>${t("chat.pvNewerShow")}</button></div>` : null}
195
+ ${going ? html`<div class="pv-banner">${P().BANNER}</div>` : null}
196
+ ${note && view === "preview" ? html`<div class="pv-note">${note}</div>` : null}
197
+ <div class=${`pv-panel-body${view === "code" ? " show-code" : ""}`}>
198
+ <div class="pv-holder" ref=${holder}></div>
199
+ ${view === "code" ? html`<pre class="pv-code"><code>${open.code}</code></pre>` : null}
200
+ </div>
201
+ </aside>`;
202
+ }
@@ -128,8 +128,10 @@
128
128
  /**
129
129
  * Run `html` in `holder` (a node of the card) for `key`. `ui` is told what happened: ui.state(name, reason) with
130
130
  * "starting" | "running" | "stopped" | "unavailable". One preview at a time: any other running one stops first.
131
+ * opts.fill (#1481, the side panel): the frame fills its holder — the same frame from mountPreview, only sized by the
132
+ * panel instead of by the frame's height messages, which it then ignores.
131
133
  */
132
- function start(key, holder, html, ui) {
134
+ function start(key, holder, html, ui, opts) {
133
135
  var a = availability();
134
136
  if (!a.ok) { ui.state("unavailable", a.reason); return false; }
135
137
  if (typeof html !== "string" || utf8Bytes(html) > LIMITS.maxBytes) { ui.state("unavailable", "This HTML is over 1 MiB; it is not previewed."); return false; }
@@ -139,14 +141,15 @@
139
141
  var card = {
140
142
  key: key, iframe: iframe, ch: randomCh(), holder: holder, html: html, ui: ui, rendered: false,
141
143
  height: LIMITS.minHeight, lastApplied: -Infinity, appliedInSecond: [], frozen: false, growth: [], pendingFrame: false,
142
- readyTimer: null, watchdogTimer: null,
144
+ readyTimer: null, watchdogTimer: null, fill: !!(opts && opts.fill),
143
145
  };
146
+ if (card.fill) iframe.classList.add("fill");
144
147
  live.set(key, card);
145
148
  card.readyTimer = root.setTimeout(function () {
146
149
  if (live.get(key) === card && !card.rendered) stop(key, "unavailable", "Preview unavailable: the preview did not answer within 3 s. If an earlier preview froze, it may still be running — open the dashboard in a new tab. Over SSH, forward the preview port too.");
147
150
  }, LIMITS.readyMs);
148
151
  holder.appendChild(iframe);
149
- setHeight(card, LIMITS.minHeight);
152
+ if (!card.fill) setHeight(card, LIMITS.minHeight);
150
153
  ui.state("starting", "");
151
154
  return true;
152
155
  }
@@ -235,6 +238,7 @@
235
238
  /** Height rules (design §4.2): clamp to [40, 4000]; ≤ 1 per animation frame and ≤ 10 per second; Δ < 2 px ignored;
236
239
  * after 5 consecutive increases within 2 s the height freezes and the frame scrolls. */
237
240
  function requestHeight(card, h) {
241
+ if (card.fill) return; // the panel sizes it (#1481)
238
242
  var want = Math.max(LIMITS.minHeight, Math.min(LIMITS.maxHeight, h));
239
243
  if (Math.abs(want - card.height) < LIMITS.minDelta) return;
240
244
  var t = now();
@@ -62,7 +62,7 @@ register("settings", {
62
62
  se_primaryServer: "primary",
63
63
  se_externalEmojiHint: "Another server's emoji needs the bot's Use External Emojis permission in the channel it reacts in.",
64
64
  se_unavailable: "unavailable",
65
- se_hint: "Empty keeps the default. The preview is resolved by AgEnD exactly as the bot will react.",
65
+ se_hint: "Empty keeps the default. The preview is resolved by AgEnD exactly as the bot will react. Progress prefix is not a reaction: it starts the progress message.",
66
66
  detection: "Detection",
67
67
  startup: "Startup",
68
68
  backendSection: "Backend",
@@ -217,6 +217,7 @@ register("settings", {
217
217
  countAgents: "{0} agents",
218
218
  countBots: "{0} connections",
219
219
  countRooms: "{0} rooms",
220
+ countAgent: "1 agent", countBot: "1 connection", countRoom: "1 room",
220
221
  newAgent: "New agent",
221
222
  newBot: "New connection",
222
223
  effortTag: "effort: {0}",
@@ -351,7 +352,7 @@ register("settings", {
351
352
  se_primaryServer: "主伺服器",
352
353
  se_externalEmojiHint: "使用其他伺服器的 emoji,bot 在要加反應的頻道需有「使用外部表情符號」權限。",
353
354
  se_unavailable: "無法使用",
354
- se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。",
355
+ se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。進度前綴不是反應,而是進度訊息開頭的 emoji。",
355
356
  title: "設定",
356
357
  myAgents: "我的 Agent",
357
358
  bots: "機器人",
@@ -540,6 +541,7 @@ register("settings", {
540
541
  countAgents: "{0} 個 agent",
541
542
  countBots: "{0} 個連線",
542
543
  countRooms: "{0} 個房間",
544
+ countAgent: "1 個 agent", countBot: "1 個連線", countRoom: "1 個房間",
543
545
  newAgent: "新增 agent",
544
546
  newBot: "新增連線",
545
547
  effortTag: "effort:{0}",
@@ -1,7 +1,7 @@
1
1
  // #1408 §3: the app's routes on the client. The same table as the server's classifier (src/web-shell-routes.ts;
2
2
  // a test runs both over one list of cases). Pure: no DOM, no history.
3
3
 
4
- export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "config"];
4
+ export const FLEET_TABS = ["tasks", "schedules", "teams", "org", "cache", "config"];
5
5
  export const SETTINGS_SECTIONS = ["agents", "bots", "classic", "general", "advanced"];
6
6
  const MAX_INSTANCE_NAME = 128;
7
7
  const PATH_SEPARATOR_OR_CONTROL = /[/\\\u0000-\u001f\u007f]/;
@@ -245,14 +245,16 @@ const mmss = (ms) => { const s = Math.max(0, Math.ceil(ms / 1000)); return `${Ma
245
245
 
246
246
  /**
247
247
  * #1423: Settings writes an admin has to confirm, from whichever panel made them (settings-confirm.js publishes them to
248
- * appStore.pendingChanges). What it asks, how to confirm it, the countdown, Withdraw — in any panel.
248
+ * appStore.pendingChanges). What it asks, how to confirm it, the countdown, Withdraw — in any panel: a card in the
249
+ * corner, except in Settings itself, which shows the same cards in its column, right under the operation (`inline`),
250
+ * so they never cover it (alpha.2 sweep).
249
251
  */
250
- function PendingChanges() {
252
+ export function PendingChanges({ inline = false } = {}) {
251
253
  const { pendingChanges } = useStore(appStore);
252
254
  const list = pendingChanges || [];
253
255
  useTick(list.some(p => p.state === "pending"));
254
256
  if (!list.length) return null;
255
- return html`<div class="pending-stack" role="region" aria-label=${t("app.pendingTitle")}>${list.map(p => html`<section key=${p.id} class=${`pending-card ${p.state}`} role="status">
257
+ return html`<div class=${`pending-stack${inline ? " inline" : ""}`} role="region" aria-label=${t("app.pendingTitle")}>${list.map(p => html`<section key=${p.id} class=${`pending-card ${p.state}`} role="status">
256
258
  <div class="pending-head"><${Icon} name=${p.state === "applied" ? "check" : p.state === "pending" || p.state === "applying" ? "clock" : "alert"} size=${16} />
257
259
  <strong>${t(`app.pending_${p.state}`)}</strong>
258
260
  ${p.state === "pending" ? html`<span class="pending-left">${t("app.pendingLeft", mmss(p.deadline - NOW()))}</span>` : null}
@@ -336,7 +338,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
336
338
  </main>
337
339
  <${BottomTabs} route=${nav.route} viewOnly=${viewOnly} />
338
340
  ${shell.dialog ? html`<${shell.dialog.Component} ...${shell.dialog.props} onClose=${closeDialog} />` : null}
339
- ${viewOnly ? null : html`<${PendingChanges} />`}
341
+ ${viewOnly || (nav.route && nav.route.panel === "settings") ? null : html`<${PendingChanges} />`}
340
342
  <${ConfirmHost} />
341
343
  <${Toasts} />
342
344
  </div>`;