@songsid/agend 2.2.0-beta.3 → 2.2.0-beta.5

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 (139) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/backend/claude-code.d.ts +46 -2
  4. package/dist/backend/claude-code.js +125 -31
  5. package/dist/backend/claude-code.js.map +1 -1
  6. package/dist/backend/credential-profile.d.ts +33 -3
  7. package/dist/backend/credential-profile.js +107 -5
  8. package/dist/backend/credential-profile.js.map +1 -1
  9. package/dist/backend/types.d.ts +1 -1
  10. package/dist/cache-service.d.ts +2 -0
  11. package/dist/cache-service.js +2 -1
  12. package/dist/cache-service.js.map +1 -1
  13. package/dist/channel/adapters/discord.d.ts +13 -0
  14. package/dist/channel/adapters/discord.js +42 -0
  15. package/dist/channel/adapters/discord.js.map +1 -1
  16. package/dist/channel/adapters/telegram.d.ts +6 -1
  17. package/dist/channel/adapters/telegram.js +27 -2
  18. package/dist/channel/adapters/telegram.js.map +1 -1
  19. package/dist/channel/types.d.ts +22 -1
  20. package/dist/cli.js +35 -0
  21. package/dist/cli.js.map +1 -1
  22. package/dist/config-validator.js +7 -0
  23. package/dist/config-validator.js.map +1 -1
  24. package/dist/daemon.d.ts +96 -0
  25. package/dist/daemon.js +339 -16
  26. package/dist/daemon.js.map +1 -1
  27. package/dist/delivery-outbox.d.ts +18 -5
  28. package/dist/delivery-outbox.js +121 -14
  29. package/dist/delivery-outbox.js.map +1 -1
  30. package/dist/fleet-context.d.ts +1 -0
  31. package/dist/fleet-manager.d.ts +89 -3
  32. package/dist/fleet-manager.js +541 -86
  33. package/dist/fleet-manager.js.map +1 -1
  34. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  35. package/dist/host-memory.d.ts +10 -0
  36. package/dist/host-memory.js +33 -1
  37. package/dist/host-memory.js.map +1 -1
  38. package/dist/instance-lifecycle.d.ts +25 -6
  39. package/dist/instance-lifecycle.js +73 -26
  40. package/dist/instance-lifecycle.js.map +1 -1
  41. package/dist/locale.js +48 -18
  42. package/dist/locale.js.map +1 -1
  43. package/dist/login-controller.d.ts +20 -3
  44. package/dist/login-controller.js +71 -19
  45. package/dist/login-controller.js.map +1 -1
  46. package/dist/login-flows.d.ts +5 -2
  47. package/dist/login-flows.js +5 -4
  48. package/dist/login-flows.js.map +1 -1
  49. package/dist/memory-pressure.d.ts +13 -1
  50. package/dist/memory-pressure.js +50 -10
  51. package/dist/memory-pressure.js.map +1 -1
  52. package/dist/outbound-handlers.d.ts +61 -0
  53. package/dist/outbound-handlers.js +100 -35
  54. package/dist/outbound-handlers.js.map +1 -1
  55. package/dist/outbound-schemas.d.ts +1 -0
  56. package/dist/outbound-schemas.js +2 -0
  57. package/dist/outbound-schemas.js.map +1 -1
  58. package/dist/public-preview.d.ts +100 -0
  59. package/dist/public-preview.js +233 -0
  60. package/dist/public-preview.js.map +1 -0
  61. package/dist/public-web-gateway.js +6 -4
  62. package/dist/public-web-gateway.js.map +1 -1
  63. package/dist/public-web-link.d.ts +20 -0
  64. package/dist/public-web-link.js +26 -1
  65. package/dist/public-web-link.js.map +1 -1
  66. package/dist/restart-progress.d.ts +29 -0
  67. package/dist/restart-progress.js +23 -0
  68. package/dist/restart-progress.js.map +1 -1
  69. package/dist/settings-api.js +10 -4
  70. package/dist/settings-api.js.map +1 -1
  71. package/dist/spawn-gate.js +5 -1
  72. package/dist/spawn-gate.js.map +1 -1
  73. package/dist/spawn-hold-reply.d.ts +12 -0
  74. package/dist/spawn-hold-reply.js +39 -0
  75. package/dist/spawn-hold-reply.js.map +1 -0
  76. package/dist/topic-commands.js +9 -2
  77. package/dist/topic-commands.js.map +1 -1
  78. package/dist/tunnel/purpose-lane.d.ts +5 -1
  79. package/dist/tunnel/purpose-lane.js +14 -7
  80. package/dist/tunnel/purpose-lane.js.map +1 -1
  81. package/dist/turn-resume.d.ts +2 -0
  82. package/dist/turn-resume.js +1 -1
  83. package/dist/turn-resume.js.map +1 -1
  84. package/dist/types.d.ts +8 -0
  85. package/dist/ui/chat-mentions.js +63 -0
  86. package/dist/ui/chat-render.js +25 -0
  87. package/dist/ui/chat-strings.js +14 -2
  88. package/dist/ui/chat-thread.js +28 -10
  89. package/dist/ui/panel-chat.js +26 -11
  90. package/dist/ui/panel-details.js +2 -0
  91. package/dist/ui/panel-fleet.js +199 -57
  92. package/dist/ui/panel-needs.js +4 -3
  93. package/dist/ui/panel-settings.js +4 -2
  94. package/dist/ui/preview-panel.js +48 -14
  95. package/dist/ui/preview-status.js +28 -0
  96. package/dist/ui/preview.js +40 -3
  97. package/dist/ui/settings-token.js +1 -1
  98. package/dist/ui/shared/app-i18n.js +4 -0
  99. package/dist/ui/shared/app-prefs.js +1 -0
  100. package/dist/ui/shared/app-shell.js +146 -2
  101. package/dist/ui/shared/app-store.js +3 -1
  102. package/dist/ui/shared/app.css +47 -20
  103. package/dist/ui/shared/instance-nav.js +79 -23
  104. package/dist/ui/shared/view-strings.js +10 -0
  105. package/dist/unrecognised-prompt.d.ts +23 -0
  106. package/dist/unrecognised-prompt.js +74 -0
  107. package/dist/unrecognised-prompt.js.map +1 -0
  108. package/dist/update-install.d.ts +8 -0
  109. package/dist/update-install.js +9 -3
  110. package/dist/update-install.js.map +1 -1
  111. package/dist/update-marker.d.ts +40 -0
  112. package/dist/update-marker.js +49 -0
  113. package/dist/update-marker.js.map +1 -1
  114. package/dist/usage/i18n-keys.d.ts +1 -1
  115. package/dist/usage/i18n-keys.js +1 -1
  116. package/dist/usage/i18n-keys.js.map +1 -1
  117. package/dist/usage/providers.d.ts +34 -5
  118. package/dist/usage/providers.js +160 -27
  119. package/dist/usage/providers.js.map +1 -1
  120. package/dist/usage/statusline-usage.d.ts +6 -2
  121. package/dist/usage/statusline-usage.js +10 -4
  122. package/dist/usage/statusline-usage.js.map +1 -1
  123. package/dist/web-api.d.ts +20 -0
  124. package/dist/web-api.js +216 -30
  125. package/dist/web-api.js.map +1 -1
  126. package/dist/web-auth.js +3 -2
  127. package/dist/web-auth.js.map +1 -1
  128. package/dist/web-mentions.d.ts +44 -0
  129. package/dist/web-mentions.js +75 -0
  130. package/dist/web-mentions.js.map +1 -0
  131. package/dist/web-prefs.js +2 -0
  132. package/dist/web-prefs.js.map +1 -1
  133. package/dist/web-preview.d.ts +14 -1
  134. package/dist/web-preview.js +35 -13
  135. package/dist/web-preview.js.map +1 -1
  136. package/dist/web-schedules.d.ts +12 -0
  137. package/dist/web-schedules.js +34 -0
  138. package/dist/web-schedules.js.map +1 -0
  139. package/package.json +1 -1
@@ -19,10 +19,13 @@ import { toast } from "/assets/ui-toast.js";
19
19
  import { confirmDialog } from "/assets/ui-confirm.js";
20
20
  import { confirmedWrite } from "./settings-confirm.js";
21
21
  import { OrgChart } from "./fleet-org.js";
22
+ import { labelOf } from "/assets/instance-nav.js";
22
23
  import { CacheReport } from "./fleet-cache.js";
23
24
 
24
25
  register("fleet", {
25
26
  en: {
27
+ hostMemory_elevated: "Host memory pressure: elevated — new agents start one at a time, at least 5 s apart",
28
+ hostMemory_critical: "Host memory pressure: critical — new agents wait for memory to recover",
26
29
  title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
27
30
  newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
28
31
  noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
@@ -31,9 +34,17 @@ register("fleet", {
31
34
  taskTitle: "Title", taskDesc: "Description", priority: "Priority", assignee: "Assignee", unassigned: "Unassigned",
32
35
  pNormal: "Normal", pLow: "Low", pHigh: "High", pUrgent: "Urgent",
33
36
  cron: "Cron expression", message: "Message", target: "Target instance", label: "Label", teamName: "Team name", members: "Members",
37
+ // #1724: schedules in words; Edit; once or repeating
38
+ schedOnce: "Once · {0}", schedNext: "next {0}", schedRetrying: "retrying", schedDisabled: "disabled", schedSilent: "silent", schedTz: "Timezone: {0}",
39
+ editSchedule: "Edit schedule", edit: "Edit", scheduleSaved: "Schedule saved", schedRepeat: "Repeat", schedOnceMode: "Once",
40
+ schedWhen: "Date and time", schedTimezone: "Timezone", schedEnabled: "Enabled", schedWhenIn: "In {0}", schedWhenRequired: "Pick a date and time",
41
+ schedTzBad: "Unknown timezone: {0}",
42
+ cronEveryMinute: "Every minute", cronEveryMinutes: "Every {0} minutes", cronEveryHours: "Every {0} hours", cronDaily: "Daily {0}",
43
+ cronWeekdays: "Weekdays {0}", cronDays: "{0} {1}", cronMonthly: "Day {0} of each month {1}", dayNames: "Sun,Mon,Tue,Wed,Thu,Fri,Sat", dayJoin: ", ",
34
44
  titleRequired: "Title required", scheduleFieldsRequired: "Cron, message, and target required", teamFieldsRequired: "Name and at least one member required",
35
45
  deleteSchedule: "Delete this schedule?", deleteTeam: "Delete team “{0}”?", taskCreated: "Task created", scheduleCreated: "Schedule created", scheduleDeleted: "Schedule deleted",
36
- teamCreated: "Team created", teamDeleted: "Team deleted", taskDone: "Task updated", configSaved: "Config saved", configRestart: " (restart the fleet to apply channel changes)",
46
+ teamCreated: "Team created", teamDeleted: "Team deleted", teamUpdated: "Team updated", edit: "Edit", editTeam: "Edit team",
47
+ teamNoRename: "Renaming isn't supported — create a new team and delete this one.", taskDone: "Task updated", configSaved: "Config saved", configRestart: " (restart the fleet to apply channel changes)",
37
48
  channel: "Channel", type: "Type", tokenEnv: "Bot token env", groupId: "Group ID", show: "Show", hide: "Hide", access: "Access control", mode: "Mode", locked: "Locked", open: "Open",
38
49
  allowedUsers: "Allowed users", allowedUsersNote: "Comma-separated user IDs", defaults: "Defaults", backend: "Backend", roots: "Project roots", addRoot: "Add root", removeRoot: "Remove",
39
50
  saveNote: "Saving rewrites fleet.yaml (its comments are lost).",
@@ -43,6 +54,8 @@ register("fleet", {
43
54
  fleetDefault: "Use the fleet default", notInstalled: "(not installed)", deprecated: "(deprecated)", instanceCreated: "Instance created",
44
55
  },
45
56
  "zh-TW": {
57
+ hostMemory_elevated: "主機記憶體壓力偏高:新的 agent 一次啟動一個,至少間隔 5 秒",
58
+ hostMemory_critical: "主機記憶體壓力嚴重:新的 agent 會等記憶體恢復再啟動",
46
59
  title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
47
60
  newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
48
61
  noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
@@ -51,9 +64,16 @@ register("fleet", {
51
64
  taskTitle: "標題", taskDesc: "說明", priority: "優先順序", assignee: "負責人", unassigned: "未指派",
52
65
  pNormal: "一般", pLow: "低", pHigh: "高", pUrgent: "緊急",
53
66
  cron: "Cron 表示式", message: "訊息", target: "目標 instance", label: "標籤", teamName: "Team 名稱", members: "成員",
67
+ schedOnce: "一次 · {0}", schedNext: "下次 {0}", schedRetrying: "重試中", schedDisabled: "已停用", schedSilent: "靜默", schedTz: "時區:{0}",
68
+ editSchedule: "編輯排程", edit: "編輯", scheduleSaved: "已儲存排程", schedRepeat: "重複", schedOnceMode: "一次",
69
+ schedWhen: "日期與時間", schedTimezone: "時區", schedEnabled: "啟用", schedWhenIn: "以 {0} 時間", schedWhenRequired: "請選擇日期與時間",
70
+ schedTzBad: "不認得的時區:{0}",
71
+ cronEveryMinute: "每分鐘", cronEveryMinutes: "每 {0} 分鐘", cronEveryHours: "每 {0} 小時", cronDaily: "每天 {0}",
72
+ cronWeekdays: "每週一到五 {0}", cronDays: "每週{0} {1}", cronMonthly: "每月 {0} 日 {1}", dayNames: "日,一,二,三,四,五,六", dayJoin: "、",
54
73
  titleRequired: "必須填寫標題", scheduleFieldsRequired: "必須填寫 Cron、訊息與目標 instance", teamFieldsRequired: "必須填寫名稱並選擇至少一名成員",
55
74
  deleteSchedule: "確定刪除此排程嗎?", deleteTeam: "確定刪除 team「{0}」嗎?", taskCreated: "Task 已建立", scheduleCreated: "排程已建立", scheduleDeleted: "排程已刪除",
56
- teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", taskDone: "Task 已更新", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
75
+ teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", teamUpdated: "Team 已更新", edit: "編輯", editTeam: "編輯 team",
76
+ teamNoRename: "不支援重新命名:請建立新的 team,再刪除這個。", taskDone: "Task 已更新", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
57
77
  channel: "頻道", type: "類型", tokenEnv: "Bot token 環境變數", groupId: "Group ID", show: "顯示", hide: "隱藏", access: "存取控制", mode: "模式", locked: "鎖定", open: "開放",
58
78
  allowedUsers: "允許的使用者", allowedUsersNote: "以逗號分隔的使用者 ID", defaults: "預設值", backend: "Backend", roots: "專案根目錄", addRoot: "新增根目錄", removeRoot: "移除",
59
79
  saveNote: "儲存會改寫 fleet.yaml(其中的註解會遺失)。",
@@ -104,8 +124,11 @@ export function FleetPanel({ route, navKey }) {
104
124
  const tab = route.tab;
105
125
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
106
126
  const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
127
+ // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
128
+ const { hostMemory } = useStore(appStore);
129
+ const sub = hostMemory ? html`<span class=${`status host-mem ${hostMemory}`} role="status"><span class=${`dot ${hostMemory === "critical" ? "bad" : "warn"}`} aria-hidden="true"></span>${t(`fleet.hostMemory_${hostMemory}`)}</span>` : null;
107
130
  return html`<div class="panel p-fleet">
108
- <${PanelHeader} title=${t("fleet.title")}><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
131
+ <${PanelHeader} title=${t("fleet.title")} sub=${sub}><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
109
132
  <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" : ""}`}
110
133
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
111
134
  <div class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
@@ -143,10 +166,27 @@ function Tasks({ lease }) {
143
166
  ${creating ? html`<${CreateTask} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
144
167
  }
145
168
 
169
+ /** #1724: what a row says about when it runs — words for a cron, "Once · <time>" for a one-shot; never empty or "null". */
170
+ export function scheduleWhen(x) {
171
+ if (x && x.cron) return cronDesc(x.cron);
172
+ if (x && x.at) return t("fleet.schedOnce", x.at_text || x.at);
173
+ return "—";
174
+ }
175
+ /** #1724: its next run as the server worked it out (fleet zone), or "retrying"; nothing when there is none (disabled). */
176
+ export function scheduleNext(x) {
177
+ if (!x || !x.enabled) return "";
178
+ if (x.retrying) return t("fleet.schedRetrying");
179
+ return x.next_run_text ? t("fleet.schedNext", x.next_run_text) : "";
180
+ }
181
+ const clip = (s, n = 60) => { const a = Array.from(String(s || "").replace(/\s+/g, " ").trim()); return a.length > n ? `${a.slice(0, n).join("")}…` : a.join(""); };
182
+
146
183
  function Schedules({ lease }) {
147
184
  const [version, setVersion] = useState(0);
148
185
  const [creating, setCreating] = useState(false);
186
+ const [editing, setEditing] = useState(null); // #1724: the schedule being edited
187
+ const { instances } = useStore(appStore);
149
188
  const state = useLoad(lease, "/ui/schedules", version);
189
+ const targetName = (name) => { const it = (instances || []).find((i) => i && i.name === name); return it ? labelOf(it) : name; };
150
190
  async function del(id) {
151
191
  if (!(await confirmDialog({ message: t("fleet.deleteSchedule"), confirmLabel: t("fleet.delete"), danger: true }))) return;
152
192
  if (!lease.current()) return; // answered after this tab went: the question no longer stands
@@ -155,21 +195,31 @@ function Schedules({ lease }) {
155
195
  }
156
196
  return html`<${Loaded} state=${state}>${(d) => {
157
197
  const list = d.schedules || [];
158
- return html`<${ListHead} count=${list.length === 1 ? t("fleet.countSchedule") : t("fleet.countSchedules", list.length)} label=${t("fleet.newSchedule")} action=${{ onClick: () => setCreating(true) }} />
159
- ${list.length ? html`<ul class="rows">${list.map(x => html`<li key=${x.id} class="row-item">
160
- <span class=${`pill ${x.enabled ? "open" : "done"}`}>${x.enabled ? t("fleet.on") : t("fleet.off")}</span>
161
- <span class="grow">${x.label || cronDesc(x.cron)}</span><span class="mono muted" title=${cronDesc(x.cron)}>${x.cron}</span>
162
- <a class="link" href=${chatPath(x.target)}>${x.target}</a>
163
- <button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(x.id)}>${t("fleet.delete")}</button></li>`)}</ul>`
198
+ // #1724: the public link lists schedules and changes none (the server refuses it too).
199
+ const writable = !publicLink();
200
+ return html`<${ListHead} count=${list.length === 1 ? t("fleet.countSchedule") : t("fleet.countSchedules", list.length)} label=${t("fleet.newSchedule")} action=${writable ? { onClick: () => setCreating(true) } : null} />
201
+ ${list.length ? html`<ul class="rows">${list.map(x => {
202
+ const next = scheduleNext(x);
203
+ const tip = [x.cron || x.at, t("fleet.schedTz", x.timezone), x.silent ? t("fleet.schedSilent") : ""].filter(Boolean).join(" · ");
204
+ return html`<li key=${x.id} class=${`row-item sched${x.enabled ? "" : " is-off"}`} title=${tip}>
205
+ <span class=${`pill ${x.enabled ? "open" : "off"}`}>${x.enabled ? t("fleet.on") : t("fleet.schedDisabled")}</span>
206
+ <span class="grow sched-text"><span class="sched-title">${x.label || clip(x.message)}</span>
207
+ <span class="sched-sub muted"><span class="sched-when">${scheduleWhen(x)}</span>${next ? html`${" · "}<span class="sched-next">${next}</span>` : null}${" · "}<a class="link" href=${chatPath(x.target)} title=${x.target}>${targetName(x.target)}</a></span></span>
208
+ ${writable ? html`<span class="sched-actions"><button type="button" class="btn btn-ghost btn-sm sched-edit" onClick=${() => setEditing({ ...x, default_timezone: d.default_timezone })}>${t("fleet.edit")}</button><button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(x.id)}>${t("fleet.delete")}</button></span>` : null}</li>`;
209
+ })}</ul>`
164
210
  : html`<${Empty} icon="clock" title=${t("fleet.noSchedules")} hint=${t("fleet.noSchedulesHint")} />`}
165
- `;
166
- }}</${Loaded}>
167
- ${creating ? html`<${CreateSchedule} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
211
+ ${creating ? html`<${ScheduleForm} defaultTz=${d.default_timezone} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}
212
+ ${editing ? html`<${ScheduleForm} schedule=${editing} defaultTz=${d.default_timezone} onClose=${() => setEditing(null)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
213
+ }}</${Loaded}>`;
168
214
  }
169
215
 
216
+ /** #1689: the public link changes no team (the server refuses it): it lists them only. */
217
+ const publicLink = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
218
+
170
219
  function Teams({ lease }) {
171
220
  const [version, setVersion] = useState(0);
172
221
  const [creating, setCreating] = useState(false);
222
+ const [editing, setEditing] = useState(null); // #1689: { name, members, description } of the team being edited
173
223
  const state = useLoad(lease, "/ui/teams", version);
174
224
  async function del(name) {
175
225
  if (!(await confirmDialog({ message: t("fleet.deleteTeam", name), confirmLabel: t("fleet.delete"), danger: true }))) return;
@@ -179,14 +229,17 @@ function Teams({ lease }) {
179
229
  }
180
230
  return html`<${Loaded} state=${state}>${(d) => {
181
231
  const entries = Object.entries(d.teams || {});
182
- return html`<${ListHead} count=${entries.length === 1 ? t("fleet.countTeam") : t("fleet.countTeams", entries.length)} label=${t("fleet.newTeam")} action=${{ onClick: () => setCreating(true) }} />
232
+ const writable = !publicLink();
233
+ return html`<${ListHead} count=${entries.length === 1 ? t("fleet.countTeam") : t("fleet.countTeams", entries.length)} label=${t("fleet.newTeam")} action=${writable ? { onClick: () => setCreating(true) } : null} />
183
234
  ${entries.length ? html`<ul class="rows">${entries.map(([name, team]) => html`<li key=${name} class="row-item">
184
235
  <span class="grow strong">${name}</span><span class="muted">${(team.members || []).join(", ")}</span>
185
- <button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(name)}>${t("fleet.delete")}</button></li>`)}</ul>`
236
+ ${writable ? html`<button type="button" class="btn btn-ghost btn-sm team-edit" onClick=${() => setEditing({ name, members: [...(team.members || [])], description: team.description || "" })}>${t("fleet.edit")}</button>
237
+ <button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(name)}>${t("fleet.delete")}</button>` : null}</li>`)}</ul>`
186
238
  : html`<${Empty} icon="team" title=${t("fleet.noTeams")} hint=${t("fleet.noTeamsHint")} />`}
187
239
  `;
188
240
  }}</${Loaded}>
189
- ${creating ? html`<${CreateTeam} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
241
+ ${creating ? html`<${CreateTeam} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}
242
+ ${editing ? html`<${CreateTeam} team=${editing} onClose=${() => setEditing(null)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
190
243
  }
191
244
 
192
245
  /** #1389: the org chart — its structure read once per opening, its live state from the app store (fleet-org.js). */
@@ -286,7 +339,7 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
286
339
  // the dialog goes at once and the shell follows the request. From then on the dialog is done: the decision is
287
340
  // said in a toast, and nothing here closes, refreshes or acts again.
288
341
  try {
289
- const res = await confirmedWrite(submit.path, { method: "POST", body, label: title,
342
+ const res = await confirmedWrite(submit.path, { method: submit.method || "POST", body, label: title,
290
343
  onPending: () => { handedOver = true; toast(t("app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
291
344
  r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
292
345
  } catch (err) { r = { error: err.message }; }
@@ -299,7 +352,7 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
299
352
  }
300
353
  return html`<${Dialog} title=${title} onClose=${onClose} busy=${busy}
301
354
  actions=${html`<button type="button" class="btn" onClick=${onClose} disabled=${busy}>${t("fleet.cancel")}</button>
302
- <button type="button" class="btn btn-primary" onClick=${go} disabled=${busy || !ready}>${busy ? t("fleet.creating") : t("fleet.create")}</button>`}>
355
+ <button type="button" class="btn btn-primary" onClick=${go} disabled=${busy || !ready}>${busy ? t("fleet.creating") : submit.label || t("fleet.create")}</button>`}>
303
356
  <form class="form" onSubmit=${(e) => { e.preventDefault(); go(); }}><fieldset class="form" disabled=${busy}>${children}</fieldset></form></${Dialog}>`;
304
357
  }
305
358
  const field = (label, input) => html`<label class="field"><span>${label}</span>${input}</label>`;
@@ -323,40 +376,120 @@ function CreateTask({ onClose, onDone }) {
323
376
  </${FormDialog}>`;
324
377
  }
325
378
 
326
- function CreateSchedule({ onClose, onDone }) {
379
+ /**
380
+ * #1724: a wall-clock date and time ("YYYY-MM-DDTHH:MM", a datetime-local value) read in `tz`, as the ISO time with
381
+ * offset the scheduler takes (`at`); null when either cannot be read. DST-safe: the offset is sampled at that instant.
382
+ */
383
+ export function wallToIso(wall, tz) {
384
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(wall || ""));
385
+ if (!m) return null;
386
+ const asUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
387
+ let offsetAt;
388
+ try {
389
+ const fmt = new Intl.DateTimeFormat("sv-SE", { timeZone: tz, hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
390
+ offsetAt = (ms) => Date.parse(fmt.format(new Date(ms)).replace(" ", "T") + "Z") - ms;
391
+ } catch { return null; }
392
+ let off = offsetAt(asUtc);
393
+ off = offsetAt(asUtc - off);
394
+ if (!Number.isFinite(off)) return null;
395
+ const mins = Math.round(off / 60000), a = Math.abs(mins);
396
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:00${mins < 0 ? "-" : "+"}${String(Math.floor(a / 60)).padStart(2, "0")}:${String(a % 60).padStart(2, "0")}`;
397
+ }
398
+ /** #1724: an ISO time as the wall clock in `tz` ("YYYY-MM-DDTHH:MM"), to fill the form; "" when it cannot be read. */
399
+ export function isoToWall(iso, tz) {
400
+ const ms = Date.parse(String(iso || ""));
401
+ if (!Number.isFinite(ms)) return "";
402
+ try {
403
+ return new Intl.DateTimeFormat("sv-SE", { timeZone: tz, hour12: false, year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" }).format(new Date(ms)).replace(" ", "T");
404
+ } catch { return ""; }
405
+ }
406
+
407
+ /**
408
+ * New schedule, or (#1724) `schedule` given: edit it — the same form, filled in from it: Repeat (cron) or Once (a date
409
+ * and time in its timezone), message, target, label, timezone, enabled. An edit is PATCH /ui/schedules/<id>, which the
410
+ * host validates and applies as the update_schedule tool does (cron and at clear each other there).
411
+ */
412
+ function ScheduleForm({ schedule = null, defaultTz, onClose, onDone }) {
327
413
  const { instances } = useStore(appStore);
328
- const [f, setF] = useState({ cron: "", msg: "", target: instances[0] ? instances[0].name : "", label: "" });
329
- const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
330
- const submit = { path: "/ui/schedules", done: t("fleet.scheduleCreated"), after: onDone, collect: () => {
331
- if (!f.cron.trim() || !f.msg.trim() || !f.target) { toast(t("fleet.scheduleFieldsRequired"), false); return null; }
332
- const body = { cron: f.cron.trim(), message: f.msg.trim(), target: f.target, source: "web-user", reply_chat_id: "", reply_thread_id: "" };
333
- if (f.label.trim()) body.label = f.label.trim();
334
- return body;
335
- } };
336
- return html`<${FormDialog} title=${t("fleet.newSchedule")} onClose=${onClose} submit=${submit}>
337
- ${field(`${t("fleet.cron")} *`, html`<input value=${f.cron} onInput=${set("cron")} placeholder="0 9 * * 1-5" class="mono" />`)}
338
- ${f.cron.trim() ? html`<p class="note">${cronDesc(f.cron)}</p>` : null}
414
+ const tz0 = (schedule && schedule.timezone) || defaultTz || "";
415
+ const [f, setF] = useState(() => ({
416
+ mode: schedule && schedule.at ? "once" : "repeat",
417
+ cron: (schedule && schedule.cron) || "",
418
+ at: schedule && schedule.at ? isoToWall(schedule.at, tz0) : "",
419
+ msg: (schedule && schedule.message) || "",
420
+ target: schedule ? schedule.target : instances[0] ? instances[0].name : "",
421
+ label: (schedule && schedule.label) || "",
422
+ tz: tz0,
423
+ enabled: schedule ? !!schedule.enabled : true,
424
+ }));
425
+ const set = (k) => (e) => { const v = e.target.type === "checkbox" ? e.target.checked : e.target.value; setF((cur) => ({ ...cur, [k]: v })); };
426
+ const submit = {
427
+ path: schedule ? `/ui/schedules/${encodeURIComponent(schedule.id)}` : "/ui/schedules",
428
+ method: schedule ? "PATCH" : "POST",
429
+ label: schedule ? t("fleet.save") : undefined,
430
+ done: schedule ? t("fleet.scheduleSaved") : t("fleet.scheduleCreated"),
431
+ after: onDone,
432
+ collect: () => {
433
+ if (!f.msg.trim() || !f.target || (f.mode === "repeat" && !f.cron.trim())) { toast(t("fleet.scheduleFieldsRequired"), false); return null; }
434
+ const tz = f.tz.trim();
435
+ const body = { message: f.msg.trim(), target: f.target };
436
+ if (f.mode === "repeat") body.cron = f.cron.trim();
437
+ else {
438
+ if (!f.at) { toast(t("fleet.schedWhenRequired"), false); return null; }
439
+ const at = wallToIso(f.at, tz || defaultTz);
440
+ if (!at) { toast(t("fleet.schedTzBad", tz || defaultTz || ""), false); return null; }
441
+ body.at = at;
442
+ }
443
+ if (schedule) { body.label = f.label.trim(); body.enabled = !!f.enabled; if (tz) body.timezone = tz; }
444
+ else { if (f.label.trim()) body.label = f.label.trim(); if (tz) body.timezone = tz; }
445
+ return body;
446
+ },
447
+ };
448
+ const modeBtn = (mode, label) => html`<button type="button" class=${`btn btn-sm${f.mode === mode ? " on" : ""}`} aria-pressed=${f.mode === mode ? "true" : "false"}
449
+ onClick=${() => setF((cur) => ({ ...cur, mode }))}>${label}</button>`;
450
+ return html`<${FormDialog} title=${schedule ? t("fleet.editSchedule") : t("fleet.newSchedule")} onClose=${onClose} submit=${submit}>
451
+ <div class="seg-inline sched-mode" role="group" aria-label=${t("fleet.schedWhen")}>${modeBtn("repeat", t("fleet.schedRepeat"))}${modeBtn("once", t("fleet.schedOnceMode"))}</div>
452
+ ${f.mode === "repeat"
453
+ ? html`${field(`${t("fleet.cron")} *`, html`<input value=${f.cron} onInput=${set("cron")} placeholder="0 9 * * 1-5" class="mono" />`)}
454
+ ${f.cron.trim() ? html`<p class="note sched-preview">${cronDesc(f.cron)}</p>` : null}`
455
+ : html`${field(`${t("fleet.schedWhen")} *`, html`<input type="datetime-local" value=${f.at} onInput=${set("at")} />`)}
456
+ <p class="note">${t("fleet.schedWhenIn", f.tz.trim() || defaultTz || "")}</p>`}
339
457
  ${field(`${t("fleet.message")} *`, html`<input value=${f.msg} onInput=${set("msg")} />`)}
340
- ${field(`${t("fleet.target")} *`, html`<select value=${f.target} onChange=${set("target")}>${instances.map(i => html`<option key=${i.name} value=${i.name}>${i.name}</option>`)}</select>`)}
458
+ ${field(`${t("fleet.target")} *`, html`<select value=${f.target} onChange=${set("target")}>${instances.map(i => html`<option key=${i.name} value=${i.name}>${labelOf(i)}</option>`)}</select>`)}
341
459
  ${field(t("fleet.label"), html`<input value=${f.label} onInput=${set("label")} />`)}
460
+ ${field(t("fleet.schedTimezone"), html`<input value=${f.tz} onInput=${set("tz")} placeholder=${defaultTz || "Asia/Taipei"} />`)}
461
+ ${schedule ? html`<label class="check"><input type="checkbox" checked=${f.enabled} onChange=${set("enabled")} /><span>${t("fleet.schedEnabled")}</span></label>` : null}
342
462
  </${FormDialog}>`;
343
463
  }
344
464
 
345
- function CreateTeam({ onClose, onDone }) {
465
+ /**
466
+ * New team, or (#1689) `team` given: edit that one — the same form, filled in from it: its members ticked (one no longer
467
+ * in the fleet listed too, so it can be taken out), its description; the name is not editable (a team is not renamed).
468
+ * Saved with PATCH /ui/teams/<name> (the update_team path on the host).
469
+ */
470
+ function CreateTeam({ onClose, onDone, team = null }) {
346
471
  const { instances } = useStore(appStore);
347
- const [f, setF] = useState({ name: "", desc: "", members: [] });
348
- const toggle = (n) => setF({ ...f, members: f.members.includes(n) ? f.members.filter(x => x !== n) : [...f.members, n] });
349
- const submit = { path: "/ui/teams", done: t("fleet.teamCreated"), after: onDone, collect: () => {
350
- if (!f.name.trim() || !f.members.length) { toast(t("fleet.teamFieldsRequired"), false); return null; }
351
- const body = { name: f.name.trim(), members: f.members };
352
- if (f.desc.trim()) body.description = f.desc.trim();
353
- return body;
354
- } };
355
- return html`<${FormDialog} title=${t("fleet.newTeam")} onClose=${onClose} submit=${submit}>
356
- ${field(`${t("fleet.teamName")} *`, html`<input value=${f.name} onInput=${(e) => setF({ ...f, name: e.target.value })} />`)}
357
- ${field(t("fleet.taskDesc"), html`<input value=${f.desc} onInput=${(e) => setF({ ...f, desc: e.target.value })} />`)}
358
- <fieldset class="field"><legend>${t("fleet.members")} *</legend><div class="checks">${instances.map(i => html`<label key=${i.name} class="check">
359
- <input type="checkbox" checked=${f.members.includes(i.name)} onChange=${() => toggle(i.name)} />${i.name}</label>`)}</div></fieldset>
472
+ const [f, setF] = useState(team ? { name: team.name, desc: team.description || "", members: [...team.members] } : { name: "", desc: "", members: [] });
473
+ // From the state as it is, not this render's copy: two ticks before the next render must both count.
474
+ const toggle = (n) => setF((cur) => ({ ...cur, members: cur.members.includes(n) ? cur.members.filter(x => x !== n) : [...cur.members, n] }));
475
+ const names = [...new Set([...instances.map(i => i.name), ...(team ? team.members : [])])];
476
+ const submit = team
477
+ ? { path: `/ui/teams/${encodeURIComponent(team.name)}`, method: "PATCH", label: t("fleet.save"), done: t("fleet.teamUpdated"), after: onDone, collect: () => {
478
+ if (!f.members.length) { toast(t("fleet.teamFieldsRequired"), false); return null; }
479
+ return { members: f.members, description: f.desc.trim() };
480
+ } }
481
+ : { path: "/ui/teams", done: t("fleet.teamCreated"), after: onDone, collect: () => {
482
+ if (!f.name.trim() || !f.members.length) { toast(t("fleet.teamFieldsRequired"), false); return null; }
483
+ const body = { name: f.name.trim(), members: f.members };
484
+ if (f.desc.trim()) body.description = f.desc.trim();
485
+ return body;
486
+ } };
487
+ return html`<${FormDialog} title=${team ? t("fleet.editTeam") : t("fleet.newTeam")} onClose=${onClose} submit=${submit}>
488
+ ${field(`${t("fleet.teamName")} *`, html`<input value=${f.name} readOnly=${!!team} aria-readonly=${team ? "true" : undefined} onInput=${(e) => { const v = e.target.value; setF((cur) => ({ ...cur, name: v })); }} />`)}
489
+ ${team ? html`<p class="note">${t("fleet.teamNoRename")}</p>` : null}
490
+ ${field(t("fleet.taskDesc"), html`<input value=${f.desc} onInput=${(e) => { const v = e.target.value; setF((cur) => ({ ...cur, desc: v })); }} />`)}
491
+ <fieldset class="field"><legend>${t("fleet.members")} *</legend><div class="checks">${names.map(n => html`<label key=${n} class="check">
492
+ <input type="checkbox" checked=${f.members.includes(n)} onChange=${() => toggle(n)} />${n}</label>`)}</div></fieldset>
360
493
  </${FormDialog}>`;
361
494
  }
362
495
 
@@ -409,20 +542,29 @@ export function CreateInstanceDialog({ onClose }) {
409
542
  </${FormDialog}>`;
410
543
  }
411
544
 
412
- // ── Cron in words ──
413
- const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
545
+ // ── Cron in words (#1724: in the page's language) ──
546
+ /**
547
+ * A cron expression as a person says it — "Weekdays 18:00", 「每週一到五 18:00」, "Every 15 minutes" — or the expression
548
+ * itself when it is not one of these shapes. Never "" for an expression; "" only for none.
549
+ */
414
550
  export function cronDesc(expr) {
415
- if (!expr) return "";
551
+ if (!expr || typeof expr !== "string") return "";
416
552
  const p = expr.trim().split(/\s+/);
417
- if (p.length < 5) return expr;
553
+ if (p.length !== 5) return expr.trim();
418
554
  const [min, hr, dom, mon, dow] = p;
419
- const time = hr !== "*" && min !== "*" ? `at ${hr.padStart(2, "0")}:${min.padStart(2, "0")}` : "";
420
- if (min === "*" && hr === "*") return "Every minute";
421
- if (min.startsWith("*/")) return `Every ${min.slice(2)} minutes`;
422
- if (hr.startsWith("*/")) return `Every ${hr.slice(2)} hours`;
423
- if (dom === "*" && mon === "*" && dow === "*") return `Daily ${time}`;
424
- if (dom === "*" && mon === "*" && dow === "1-5") return `Weekdays ${time}`;
425
- if (dom === "*" && mon === "*" && dow !== "*") return `${dow.split(",").map(d => { const n = parseInt(d, 10); return Number.isNaN(n) ? d : (DAYS[n] || d); }).join(", ")} ${time}`;
426
- if (dom !== "*" && mon === "*") return `${dom}${dom === "1" ? "st" : dom === "2" ? "nd" : dom === "3" ? "rd" : "th"} of month ${time}`;
427
- return expr;
555
+ const num = (s) => /^\d{1,2}$/.test(s);
556
+ const time = num(hr) && num(min) ? `${hr.padStart(2, "0")}:${min.padStart(2, "0")}` : null;
557
+ if (mon !== "*") return expr.trim();
558
+ if (min === "*" && hr === "*" && dom === "*" && dow === "*") return t("fleet.cronEveryMinute");
559
+ if (/^\*\/\d+$/.test(min) && hr === "*" && dom === "*" && dow === "*") return t("fleet.cronEveryMinutes", min.slice(2));
560
+ if (num(min) && /^\*\/\d+$/.test(hr) && dom === "*" && dow === "*") return t("fleet.cronEveryHours", hr.slice(2));
561
+ if (!time) return expr.trim();
562
+ if (dom === "*" && dow === "*") return t("fleet.cronDaily", time);
563
+ if (dom === "*" && dow === "1-5") return t("fleet.cronWeekdays", time);
564
+ if (dom === "*" && /^[0-7](,[0-7])*$/.test(dow)) {
565
+ const names = t("fleet.dayNames").split(",");
566
+ return t("fleet.cronDays", dow.split(",").map((d) => names[Number(d) % 7]).join(t("fleet.dayJoin")), time);
567
+ }
568
+ if (num(dom) && dow === "*") return t("fleet.cronMonthly", String(Number(dom)), time);
569
+ return expr.trim();
428
570
  }
@@ -15,7 +15,7 @@ import { appStore, createStore, useStore } from "/assets/app-store.js";
15
15
  import { useLease } from "/assets/app-ctx.js";
16
16
  import { PanelHeader, setTitle } from "/assets/app-shell.js";
17
17
  import { chatPath } from "/assets/app-route.js";
18
- import { labelOf } from "/assets/instance-nav.js";
18
+ import { namesOf, namesStore } from "/assets/instance-nav.js";
19
19
  import { Empty } from "/assets/ui-states.js";
20
20
  import { Icon } from "/assets/ui-icons.js";
21
21
  import { toast } from "/assets/ui-toast.js";
@@ -109,8 +109,9 @@ export function groupNeeds(items) {
109
109
  export function NeedsPanel({ navKey }) {
110
110
  const lease = useLease(navKey);
111
111
  const { needs, chatOwner: owner, instances } = useStore(appStore);
112
- // #1630: a group is headed by the instance's display name (its name on hover), as the sidebar shows it.
113
- const nameFor = (instance) => { const it = (instances || []).find((i) => i && i.name === instance); return it ? labelOf(it) : instance; };
112
+ const names = useStore(namesStore);
113
+ // #1630/#1722: a group is headed by the instance's leading name (the "Names" preference), as the sidebar shows it.
114
+ const nameFor = (instance) => { const it = (instances || []).find((i) => i && i.name === instance); return it ? namesOf(it, names.primary).primary : instance; };
114
115
  const items = Array.isArray(needs) ? needs : [];
115
116
  const [, tick] = useState(0);
116
117
  useEffect(() => { setTitle(tn("title")); }, [navKey]);
@@ -372,11 +372,13 @@ function makeCtx(data, setData, stage, unstage, reload, reloadLive, alive) {
372
372
  async pauseWake(name, action) {
373
373
  const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${action}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
374
374
  if (!res.ok) toast((res.body && res.body.error) || tn("actionFailed", action), false);
375
+ else if (res.body && res.body.held) toast(res.body.message);
375
376
  await reloadLive();
376
377
  },
377
378
  async startStop(name, running) {
378
379
  const res = await api(running ? `/stop/${encodeURIComponent(name)}` : `/api/instance/${encodeURIComponent(name)}/start`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
379
380
  if (!res.ok) toast(`${tn("actionFailed", running ? tn("stop") : tn("start"))}: ${(res.body && res.body.error) || res.status || ""}`, false);
381
+ else if (res.body && res.body.held) toast(res.body.message);
380
382
  await new Promise((r) => setTimeout(r, 800));
381
383
  await reloadLive();
382
384
  },
@@ -707,7 +709,7 @@ function General({ ctx }) {
707
709
  * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
708
710
  */
709
711
  export const WEB_READ_ONLY = [
710
- "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin",
712
+ "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
711
713
  "web.usage_panel", "web.provider_secrets", "web_terminal.bind", "web_terminal.tunnel",
712
714
  ];
713
715
  function WebSection({ ctx, staged }) {
@@ -847,7 +849,7 @@ function Developer({ ctx }) {
847
849
  <button type="button" class="btn btn-sm" title=${tn("copy")} onClick=${copy}><${Icon} name="copy" size=${14} /><span class="lbl">${tn("copy")}</span></button>
848
850
  <button type="button" class="btn btn-sm" title=${tn("devDownload")} onClick=${download}><${Icon} name="download" size=${14} /><span class="lbl">${tn("devDownload")}</span></button>
849
851
  </div>
850
- ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck="false" wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
852
+ ${editing ? html`<textarea class="s-yaml" aria-label=${tn("devEditor")} spellcheck=${false} wrap="off" value=${draft} onInput=${(e) => setDraft(e.target.value)}></textarea>
851
853
  <div class="save-row"><button type="button" class="btn btn-primary" onClick=${save}>${tn("devApply")}</button><span class="note">${tn("devApplyHint")}</span></div>`
852
854
  : html`<pre class="s-yaml">${text}</pre>`}
853
855
  ${msg ? html`<p class=${`feedback${msg.error ? " error" : ""}`} role="status">${msg.error || msg.text}</p>` : null}
@@ -1,11 +1,14 @@
1
1
  // #1481: an agent's HTML preview (#1306) in a panel beside the conversation — a split with a draggable divider on a
2
2
  // wide screen, a full-screen sheet on a narrow one. Nothing about the preview itself changes here: the frame is the one
3
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;
4
+ // card's, only told to fill the panel. It runs on a person's click, as a card's does — and "Open in panel" is that click:
5
+ // - on an idle card it opens the panel and starts the preview there, as if Preview were clicked in the panel (#1704);
6
+ // a device that has not allowed previews gets the "Allow previews" offer instead, and allowing it starts the run;
7
+ // where previews cannot run (web.preview: false, a public link), the panel opens with nothing running;
6
8
  // - on a card whose preview is running, the same button moves that run to the panel (stopped in the card, started in
7
9
  // 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.
10
+ // - nothing runs by itself: not when the page loads, not when the panel is shown again, and a newer version of the
11
+ // block in a later reply is offered, never swapped in, and shown with Preview to click.
9
12
  // Closing the panel or leaving the chat stops its frame; one preview per page still holds (start() stops any other).
10
13
  import { html, useEffect, useRef, useState } from "/assets/app-html.js";
11
14
  import { t } from "/assets/app-i18n.js";
@@ -19,18 +22,29 @@ const P = () => globalThis.AgendPreview;
19
22
 
20
23
  // ── The panel's state, one per page (the chat shows one instance at a time) ──
21
24
 
22
- /** open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4); run: start it on mount (a moved run). */
25
+ /**
26
+ * open: { key, instance, msgKey, n | att, code, sender, ts } | null (`att`: an attached .html file, #1306 Q4);
27
+ * run: start it on mount — the card's click, as a token no later open ever reuses (0: nothing to run).
28
+ */
23
29
  let state = { open: null, run: 0 };
30
+ let runs = 0, version = 0, claims = 0;
24
31
  const subs = new Set();
25
- function set(next) { state = next; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
32
+ function set(next) { state = next; version++; for (const fn of [...subs]) { try { fn(state); } catch { /* one listener's error stops no other */ } } }
26
33
  export const panelStore = { get: () => state, subscribe(fn) { subs.add(fn); return () => { subs.delete(fn); }; } };
34
+ /** Moves on every open and close, and on every claim: what a card's click compares when its HTML arrives late. */
35
+ export const panelVersion = () => version;
36
+ /** A click that will show something in the panel (an attachment's HTML still to read): from now on it is the newest —
37
+ * any earlier click still reading is dropped when its answer comes. Returns the version the click owns. */
38
+ export function claimPanel() { claims++; return ++version; }
39
+ /** How many clicks have claimed: an inline Preview still reading asks only whether a newer click came (not a close). */
40
+ export const panelClaims = () => claims;
27
41
 
28
42
  /** The frame's key in preview.js: its own, so the card that opened it keeps its own state. */
29
43
  export const frameKey = (key) => `panel:${key}`;
30
- /** Show `spec` in the panel; `run`: start it there (a click that moves a running preview). The block shown before stops. */
44
+ /** Show `spec` in the panel; `run`: start it there (the card's "Open in panel" click). The block shown before stops. */
31
45
  export function openPanel(spec, { run = false } = {}) {
32
46
  if (state.open && P()) P().stop(frameKey(state.open.key), "stopped", "");
33
- set({ open: { ...spec }, run: run ? state.run + 1 : 0 });
47
+ set({ open: { ...spec }, run: run ? ++runs : 0 });
34
48
  }
35
49
  /** Close the panel: its frame stops first. */
36
50
  export function closePanel() {
@@ -105,7 +119,7 @@ const timeOf = (ts) => (ts ? new Date(ts).toLocaleTimeString([], { hour: "2-digi
105
119
  * props: name (the instance), msgs() → its messages, subscribe(fn) → unsubscribe (the chat store's), keyOf(x),
106
120
  * reveal(msgKey) (the thread shows that message), download(code).
107
121
  */
108
- /** allow(): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then setOptIn) → the choice. */
122
+ /** allow(commit): this device's opt-in through the chat's one path (setPreviewOptIn: the confirm, then commit(setOptIn)) → the choice. */
109
123
  export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, download, allow }) {
110
124
  const [st, setSt] = useState(state);
111
125
  const [view, setView] = useState("preview");
@@ -113,13 +127,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
113
127
  const [newer, setNewer] = useState(null);
114
128
  const [newerNote, setNewerNote] = useState("");
115
129
  const reading = useRef(null); // the newer version whose file is being read (one at a time)
130
+ const pending = useRef(0); // #1704: the card's click, waiting for this device's Allow (the st.run it came with)
131
+ const ownAllow = useRef(0); // while this panel's own Allow commits its choice: the click it answers (not cancelled by it)
116
132
  const holder = useRef(null), divider = useRef(null), panel = useRef(null);
117
133
  const open = st.open && st.open.instance === name ? st.open : null;
118
134
 
119
135
  useEffect(() => panelStore.subscribe(setSt), []);
120
136
  // This device's choice (here or in another tab) decides whether Preview is offered: re-read it when it changes.
137
+ // A click waiting for Allow is cancelled by any other change of that choice (Never, a card's menu, another tab):
138
+ // after it, running needs a new click — only this panel's own Allow runs the click it was offered for.
121
139
  const [, choice] = useState(0);
122
- useEffect(() => (P() ? P().onChange(() => choice(n => n + 1)) : undefined), []);
140
+ useEffect(() => (P() ? P().onChange(() => { if (pending.current && ownAllow.current !== pending.current) pending.current = 0; choice(n => n + 1); }) : undefined), []);
123
141
  // Leaving the chat (another instance, another panel) closes the panel: its frame stops with the thread's.
124
142
  useEffect(() => () => closePanel(), []);
125
143
 
@@ -130,11 +148,17 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
130
148
  P().start(frameKey(open.key), holder.current, open.code, ui, { fill: true });
131
149
  };
132
150
  const stop = () => { if (open) P().stop(frameKey(open.key), "stopped", ""); };
133
- // A new block in the panel: what ran before is gone (openPanel stopped it); a moved run starts here.
151
+ // A new block in the panel: what ran before is gone (openPanel stopped it); the card's click starts it here — or,
152
+ // where it cannot run, waits as `pending`: only the Allow below can use it (offered to a device that has not opted
153
+ // in), only while the panel shows what that click opened (st.run is a token no later open reuses), and any other
154
+ // change of this device's choice cancels it.
134
155
  useEffect(() => {
135
156
  setRun({ state: "idle", reason: "" });
136
157
  setView("preview");
137
- if (open && st.run) start();
158
+ if (open && st.run) {
159
+ if (P().availability().ok) start();
160
+ else pending.current = st.run;
161
+ }
138
162
  // Focus goes to the panel's close control that is on screen (Back on a phone, × beside the thread).
139
163
  if (panel.current) { const f = [...panel.current.querySelectorAll(".pv-panel-close")].find(b => b.offsetParent !== null); if (f) f.focus(); }
140
164
  }, [open && open.key, st.run]);
@@ -204,11 +228,21 @@ export function PreviewPanel({ name, split, msgs, subscribe, keyOf, reveal, down
204
228
  if (!open) return null;
205
229
  const going = run.state === "starting" || run.state === "running";
206
230
  const a = P().availability();
207
- // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm); Preview
208
- // is then enabled — still a click. Why a run stopped (e.g. switched off in another tab) is still said first.
231
+ // #1554: a device that has not allowed previews is offered the same "Allow previews" here (the same confirm). Allowing
232
+ // runs the block the card's click opened (#1704); otherwise Preview is then enabled — still a click. Why a run stopped
233
+ // (e.g. switched off in another tab) is still said first.
209
234
  const askOptIn = !going && !a.ok && a.why === "optin" && typeof allow === "function";
210
235
  const note = run.reason || (going ? (run.state === "starting" ? t("chat.pvStarting") : "") : a.ok ? t("chat.pvPanelIdle") : askOptIn ? t("chat.pvWhyOptinPanel") : a.reason);
211
- const allowHere = async () => { if (await allow()) setRun({ state: "idle", reason: "" }); };
236
+ // Allowed here: the click that opened the panel runs now — if it is still waiting (no other change of the choice
237
+ // cancelled it, even while the confirm was open: only the change this Allow commits is its own) and the panel still
238
+ // shows what it opened (the confirm is a wait: the panel may have moved on meanwhile).
239
+ const allowHere = async () => {
240
+ const want = pending.current;
241
+ const yes = await allow((apply) => { ownAllow.current = want; try { apply(); } finally { ownAllow.current = 0; } });
242
+ if (!yes) return;
243
+ setRun({ state: "idle", reason: "" });
244
+ if (want && pending.current === want && panelStore.get().run === want) start();
245
+ };
212
246
  const source = t("chat.pvSource", open.sender || name, timeOf(open.ts));
213
247
  return html`<div class="pv-divider" ref=${divider} role="separator" aria-orientation="vertical" tabindex="0" aria-label=${t("chat.pvResize")}
214
248
  aria-valuemin=${String(MIN_PANEL)} onKeyDown=${onKey} onPointerDown=${onDown}></div>
@@ -0,0 +1,28 @@
1
+ // #1723: the public link's preview tunnel starts beside the link and is never waited for, so a page may load while it
2
+ // is still starting. This asks the page's own server (/ui/preview-status) until it is up or failed, and hands each answer
3
+ // to preview.js — which takes only what it may use, and itself reaches no network (#1306).
4
+
5
+ /** Every 2 s, for at most 5 minutes. */
6
+ export const POLL_MS = 2000;
7
+ export const POLL_MAX = 150;
8
+
9
+ /**
10
+ * Ask while `P.waitingForPublicPreview()`; stop once `P.applyPublicPreviewStatus(answer)` says there is nothing more to
11
+ * wait for, or after POLL_MAX asks. Returns a stop function.
12
+ */
13
+ export function pollPublicPreview(P, deps = {}) {
14
+ const get = deps.fetch ?? ((...a) => fetch(...a));
15
+ const later = deps.setTimeout ?? ((fn, ms) => setTimeout(fn, ms));
16
+ if (!P || typeof P.waitingForPublicPreview !== "function" || !P.waitingForPublicPreview()) return () => {};
17
+ let attempts = 0, stopped = false;
18
+ const again = () => { if (!stopped && attempts < POLL_MAX) later(tick, POLL_MS); };
19
+ const tick = () => {
20
+ if (stopped || !P.waitingForPublicPreview()) return;
21
+ attempts++;
22
+ get("/ui/preview-status", { credentials: "same-origin", cache: "no-store" })
23
+ .then((r) => (r.ok ? r.json() : null))
24
+ .then((j) => { if (!stopped && !P.applyPublicPreviewStatus(j)) again(); }, again);
25
+ };
26
+ later(tick, POLL_MS);
27
+ return () => { stopped = true; };
28
+ }