@songsid/agend 2.2.0-beta.4 → 2.2.0-beta.6

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 (116) hide show
  1. package/dist/backend/claude-carry.d.ts +22 -0
  2. package/dist/backend/claude-carry.js +170 -0
  3. package/dist/backend/claude-carry.js.map +1 -0
  4. package/dist/backend/claude-code.d.ts +23 -0
  5. package/dist/backend/claude-code.js +102 -2
  6. package/dist/backend/claude-code.js.map +1 -1
  7. package/dist/backend/credential-profile.d.ts +7 -0
  8. package/dist/backend/credential-profile.js +7 -1
  9. package/dist/backend/credential-profile.js.map +1 -1
  10. package/dist/backend/kiro.js +5 -2
  11. package/dist/backend/kiro.js.map +1 -1
  12. package/dist/backend/muse.d.ts +41 -0
  13. package/dist/backend/muse.js +239 -44
  14. package/dist/backend/muse.js.map +1 -1
  15. package/dist/backend/types.d.ts +7 -0
  16. package/dist/backend/types.js.map +1 -1
  17. package/dist/background-work-ended.d.ts +18 -0
  18. package/dist/background-work-ended.js +30 -0
  19. package/dist/background-work-ended.js.map +1 -0
  20. package/dist/channel/adapters/telegram.d.ts +4 -5
  21. package/dist/channel/adapters/telegram.js +33 -17
  22. package/dist/channel/adapters/telegram.js.map +1 -1
  23. package/dist/channel/message-edit-errors.d.ts +5 -0
  24. package/dist/channel/message-edit-errors.js +24 -0
  25. package/dist/channel/message-edit-errors.js.map +1 -0
  26. package/dist/cli.js +60 -10
  27. package/dist/cli.js.map +1 -1
  28. package/dist/config-validator.js +3 -0
  29. package/dist/config-validator.js.map +1 -1
  30. package/dist/daemon.js +41 -0
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/fleet-context.d.ts +4 -1
  33. package/dist/fleet-manager.d.ts +112 -5
  34. package/dist/fleet-manager.js +516 -52
  35. package/dist/fleet-manager.js.map +1 -1
  36. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +44 -9
  37. package/dist/locale.js +18 -2
  38. package/dist/locale.js.map +1 -1
  39. package/dist/needs-you-hub.js +2 -0
  40. package/dist/needs-you-hub.js.map +1 -1
  41. package/dist/needs-you-live.d.ts +6 -0
  42. package/dist/needs-you-live.js +23 -3
  43. package/dist/needs-you-live.js.map +1 -1
  44. package/dist/outbound-handlers.d.ts +7 -0
  45. package/dist/outbound-handlers.js +19 -10
  46. package/dist/outbound-handlers.js.map +1 -1
  47. package/dist/public-preview.d.ts +100 -0
  48. package/dist/public-preview.js +233 -0
  49. package/dist/public-preview.js.map +1 -0
  50. package/dist/public-web-gateway.js +4 -3
  51. package/dist/public-web-gateway.js.map +1 -1
  52. package/dist/public-web-link.d.ts +20 -0
  53. package/dist/public-web-link.js +26 -1
  54. package/dist/public-web-link.js.map +1 -1
  55. package/dist/restart-progress.d.ts +33 -0
  56. package/dist/restart-progress.js +30 -0
  57. package/dist/restart-progress.js.map +1 -1
  58. package/dist/service-installer.d.ts +31 -0
  59. package/dist/service-installer.js +41 -2
  60. package/dist/service-installer.js.map +1 -1
  61. package/dist/topic-commands.js +3 -1
  62. package/dist/topic-commands.js.map +1 -1
  63. package/dist/tunnel/purpose-lane.d.ts +5 -1
  64. package/dist/tunnel/purpose-lane.js +14 -7
  65. package/dist/tunnel/purpose-lane.js.map +1 -1
  66. package/dist/types.d.ts +6 -0
  67. package/dist/ui/chat-strings.js +14 -2
  68. package/dist/ui/chat-thread.js +2 -2
  69. package/dist/ui/panel-chat.js +9 -2
  70. package/dist/ui/panel-fleet.js +148 -35
  71. package/dist/ui/panel-needs.js +4 -3
  72. package/dist/ui/panel-settings.js +1 -1
  73. package/dist/ui/preview-status.js +28 -0
  74. package/dist/ui/preview.js +35 -2
  75. package/dist/ui/shared/app-i18n.js +4 -0
  76. package/dist/ui/shared/app-prefs.js +1 -0
  77. package/dist/ui/shared/app-shell.js +146 -2
  78. package/dist/ui/shared/app-stream.js +21 -4
  79. package/dist/ui/shared/app.css +41 -15
  80. package/dist/ui/shared/instance-nav.js +47 -16
  81. package/dist/ui/shared/usage-dialog.js +10 -1
  82. package/dist/ui/shared/view-strings.js +4 -0
  83. package/dist/update-install.d.ts +13 -1
  84. package/dist/update-install.js +16 -4
  85. package/dist/update-install.js.map +1 -1
  86. package/dist/update-marker.d.ts +51 -0
  87. package/dist/update-marker.js +61 -0
  88. package/dist/update-marker.js.map +1 -1
  89. package/dist/usage/i18n-keys.d.ts +1 -1
  90. package/dist/usage/i18n-keys.js +1 -1
  91. package/dist/usage/i18n-keys.js.map +1 -1
  92. package/dist/usage/providers.d.ts +34 -5
  93. package/dist/usage/providers.js +160 -27
  94. package/dist/usage/providers.js.map +1 -1
  95. package/dist/usage/statusline-usage.d.ts +6 -2
  96. package/dist/usage/statusline-usage.js +10 -4
  97. package/dist/usage/statusline-usage.js.map +1 -1
  98. package/dist/web-api.d.ts +20 -1
  99. package/dist/web-api.js +101 -13
  100. package/dist/web-api.js.map +1 -1
  101. package/dist/web-auth.js +2 -1
  102. package/dist/web-auth.js.map +1 -1
  103. package/dist/web-prefs.js +2 -0
  104. package/dist/web-prefs.js.map +1 -1
  105. package/dist/web-preview.d.ts +14 -1
  106. package/dist/web-preview.js +35 -13
  107. package/dist/web-preview.js.map +1 -1
  108. package/dist/web-schedules.d.ts +12 -0
  109. package/dist/web-schedules.js +34 -0
  110. package/dist/web-schedules.js.map +1 -0
  111. package/dist/web-server-drain.d.ts +10 -0
  112. package/dist/web-server-drain.js +50 -0
  113. package/dist/web-server-drain.js.map +1 -0
  114. package/package.json +1 -1
  115. package/templates/launchd.plist.ejs +3 -1
  116. package/templates/systemd.service.ejs +2 -1
@@ -19,6 +19,7 @@ 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", {
@@ -33,6 +34,13 @@ register("fleet", {
33
34
  taskTitle: "Title", taskDesc: "Description", priority: "Priority", assignee: "Assignee", unassigned: "Unassigned",
34
35
  pNormal: "Normal", pLow: "Low", pHigh: "High", pUrgent: "Urgent",
35
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: ", ",
36
44
  titleRequired: "Title required", scheduleFieldsRequired: "Cron, message, and target required", teamFieldsRequired: "Name and at least one member required",
37
45
  deleteSchedule: "Delete this schedule?", deleteTeam: "Delete team “{0}”?", taskCreated: "Task created", scheduleCreated: "Schedule created", scheduleDeleted: "Schedule deleted",
38
46
  teamCreated: "Team created", teamDeleted: "Team deleted", teamUpdated: "Team updated", edit: "Edit", editTeam: "Edit team",
@@ -56,6 +64,12 @@ register("fleet", {
56
64
  taskTitle: "標題", taskDesc: "說明", priority: "優先順序", assignee: "負責人", unassigned: "未指派",
57
65
  pNormal: "一般", pLow: "低", pHigh: "高", pUrgent: "緊急",
58
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: "、",
59
73
  titleRequired: "必須填寫標題", scheduleFieldsRequired: "必須填寫 Cron、訊息與目標 instance", teamFieldsRequired: "必須填寫名稱並選擇至少一名成員",
60
74
  deleteSchedule: "確定刪除此排程嗎?", deleteTeam: "確定刪除 team「{0}」嗎?", taskCreated: "Task 已建立", scheduleCreated: "排程已建立", scheduleDeleted: "排程已刪除",
61
75
  teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", teamUpdated: "Team 已更新", edit: "編輯", editTeam: "編輯 team",
@@ -152,10 +166,27 @@ function Tasks({ lease }) {
152
166
  ${creating ? html`<${CreateTask} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
153
167
  }
154
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
+
155
183
  function Schedules({ lease }) {
156
184
  const [version, setVersion] = useState(0);
157
185
  const [creating, setCreating] = useState(false);
186
+ const [editing, setEditing] = useState(null); // #1724: the schedule being edited
187
+ const { instances } = useStore(appStore);
158
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; };
159
190
  async function del(id) {
160
191
  if (!(await confirmDialog({ message: t("fleet.deleteSchedule"), confirmLabel: t("fleet.delete"), danger: true }))) return;
161
192
  if (!lease.current()) return; // answered after this tab went: the question no longer stands
@@ -164,16 +195,22 @@ function Schedules({ lease }) {
164
195
  }
165
196
  return html`<${Loaded} state=${state}>${(d) => {
166
197
  const list = d.schedules || [];
167
- return html`<${ListHead} count=${list.length === 1 ? t("fleet.countSchedule") : t("fleet.countSchedules", list.length)} label=${t("fleet.newSchedule")} action=${{ onClick: () => setCreating(true) }} />
168
- ${list.length ? html`<ul class="rows">${list.map(x => html`<li key=${x.id} class="row-item">
169
- <span class=${`pill ${x.enabled ? "open" : "done"}`}>${x.enabled ? t("fleet.on") : t("fleet.off")}</span>
170
- <span class="grow">${x.label || cronDesc(x.cron)}</span><span class="mono muted" title=${cronDesc(x.cron)}>${x.cron}</span>
171
- <a class="link" href=${chatPath(x.target)}>${x.target}</a>
172
- <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>`
173
210
  : html`<${Empty} icon="clock" title=${t("fleet.noSchedules")} hint=${t("fleet.noSchedulesHint")} />`}
174
- `;
175
- }}</${Loaded}>
176
- ${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}>`;
177
214
  }
178
215
 
179
216
  /** #1689: the public link changes no team (the server refuses it): it lists them only. */
@@ -339,22 +376,89 @@ function CreateTask({ onClose, onDone }) {
339
376
  </${FormDialog}>`;
340
377
  }
341
378
 
342
- 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 }) {
343
413
  const { instances } = useStore(appStore);
344
- const [f, setF] = useState({ cron: "", msg: "", target: instances[0] ? instances[0].name : "", label: "" });
345
- const set = (k) => (e) => setF({ ...f, [k]: e.target.value });
346
- const submit = { path: "/ui/schedules", done: t("fleet.scheduleCreated"), after: onDone, collect: () => {
347
- if (!f.cron.trim() || !f.msg.trim() || !f.target) { toast(t("fleet.scheduleFieldsRequired"), false); return null; }
348
- const body = { cron: f.cron.trim(), message: f.msg.trim(), target: f.target, source: "web-user", reply_chat_id: "", reply_thread_id: "" };
349
- if (f.label.trim()) body.label = f.label.trim();
350
- return body;
351
- } };
352
- return html`<${FormDialog} title=${t("fleet.newSchedule")} onClose=${onClose} submit=${submit}>
353
- ${field(`${t("fleet.cron")} *`, html`<input value=${f.cron} onInput=${set("cron")} placeholder="0 9 * * 1-5" class="mono" />`)}
354
- ${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>`}
355
457
  ${field(`${t("fleet.message")} *`, html`<input value=${f.msg} onInput=${set("msg")} />`)}
356
- ${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>`)}
357
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}
358
462
  </${FormDialog}>`;
359
463
  }
360
464
 
@@ -438,20 +542,29 @@ export function CreateInstanceDialog({ onClose }) {
438
542
  </${FormDialog}>`;
439
543
  }
440
544
 
441
- // ── Cron in words ──
442
- 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
+ */
443
550
  export function cronDesc(expr) {
444
- if (!expr) return "";
551
+ if (!expr || typeof expr !== "string") return "";
445
552
  const p = expr.trim().split(/\s+/);
446
- if (p.length < 5) return expr;
553
+ if (p.length !== 5) return expr.trim();
447
554
  const [min, hr, dom, mon, dow] = p;
448
- const time = hr !== "*" && min !== "*" ? `at ${hr.padStart(2, "0")}:${min.padStart(2, "0")}` : "";
449
- if (min === "*" && hr === "*") return "Every minute";
450
- if (min.startsWith("*/")) return `Every ${min.slice(2)} minutes`;
451
- if (hr.startsWith("*/")) return `Every ${hr.slice(2)} hours`;
452
- if (dom === "*" && mon === "*" && dow === "*") return `Daily ${time}`;
453
- if (dom === "*" && mon === "*" && dow === "1-5") return `Weekdays ${time}`;
454
- if (dom === "*" && mon === "*" && dow !== "*") return `${dow.split(",").map(d => { const n = parseInt(d, 10); return Number.isNaN(n) ? d : (DAYS[n] || d); }).join(", ")} ${time}`;
455
- if (dom !== "*" && mon === "*") return `${dom}${dom === "1" ? "st" : dom === "2" ? "nd" : dom === "3" ? "rd" : "th"} of month ${time}`;
456
- 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();
457
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]);
@@ -709,7 +709,7 @@ function General({ ctx }) {
709
709
  * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
710
710
  */
711
711
  export const WEB_READ_ONLY = [
712
- "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",
713
713
  "web.usage_panel", "web.provider_secrets", "web_terminal.bind", "web_terminal.tunnel",
714
714
  ];
715
715
  function WebSection({ ctx, staged }) {
@@ -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
+ }
@@ -28,7 +28,10 @@
28
28
  var BANNER_ACK_KEY = "agend_html_preview_banner_ack";
29
29
  var BANNER = "Previews run the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out. Only preview content you trust. A preview can slow or freeze this tab.";
30
30
 
31
- var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "", reasonCode: "" };
31
+ var cfg = { dashboardOrigin: "", previewOrigin: "", boot: "", reason: "", reasonCode: "", detail: "" };
32
+ // Why it failed, in English when the app's dictionary has no word for it (chat-strings.js pvFail_*).
33
+ var FAIL_EN = { exited: "its address closed unexpectedly", "spawn-failed": "cloudflared could not start for it", "no-address": "no address was given for it",
34
+ unreachable: "its address could not be reached", "lease-held": "an earlier preview tunnel is still being cleaned up", "gateway-failed": "its local listener could not start" };
32
35
  // #1554: the page's words. `say(key, english, …values)` asks the app's dictionary (init's opts.text) and keeps the
33
36
  // English here when it has none — so this file still works, and reads the same, without the app.
34
37
  var text = null;
@@ -66,6 +69,7 @@
66
69
  boot: String((data && data.previewBoot) || ""),
67
70
  reason: String((data && data.previewReason) || ""),
68
71
  reasonCode: String((data && data.previewReasonCode) || ""),
72
+ detail: String((data && data.previewDetail) || ""),
69
73
  };
70
74
  if (opts && typeof opts.text === "function") text = opts.text;
71
75
  if (!listening && typeof root.addEventListener === "function") {
@@ -76,6 +80,29 @@
76
80
  listening = true;
77
81
  }
78
82
  }
83
+ /**
84
+ * #1723: the public link's own preview tunnel was still starting when this page loaded; preview-status.js asks the
85
+ * page's server and hands each answer here (this file reaches no network). Ready → previews run at the origin it
86
+ * names — only an https quick-tunnel origin other than this page's is ever taken: the page's frame-src allows no
87
+ * other; failed or off → the page says why. Returns true once there is nothing more to wait for.
88
+ */
89
+ function waitingForPublicPreview() { return cfg.reasonCode === "publicLinkStarting"; }
90
+ function applyPublicPreviewStatus(j) {
91
+ if (cfg.reasonCode !== "publicLinkStarting") return true;
92
+ var at = root.location && root.location.origin;
93
+ if (j && typeof j.previewOrigin === "string" && /^https:\/\/[a-z0-9-]+\.trycloudflare\.com$/.test(j.previewOrigin) && j.previewOrigin !== at
94
+ && typeof j.boot === "string" && /^[0-9a-f]{32}$/.test(j.boot)) {
95
+ cfg.previewOrigin = j.previewOrigin; cfg.boot = j.boot; cfg.reasonCode = ""; cfg.reason = "";
96
+ notify();
97
+ return true;
98
+ }
99
+ if (j && (j.code === "publicLinkPreviewFailed" || j.code === "publicLink" || j.code === "fleetOff")) {
100
+ cfg.reasonCode = j.code; cfg.reason = ""; cfg.detail = typeof j.detail === "string" ? j.detail : "";
101
+ notify();
102
+ return true;
103
+ }
104
+ return false;
105
+ }
79
106
  function onStorage(event) {
80
107
  // #1568: the banner acknowledged in another tab of this sign-in: hide it here too (nothing starts or stops).
81
108
  if (event && event.key === BANNER_ACK_KEY) { notify(); return; }
@@ -159,7 +186,11 @@
159
186
  function availability() {
160
187
  if (!cfg.previewOrigin || !cfg.boot) {
161
188
  // The server's reason, in the page's language when the server named it (a code), else as the server wrote it.
162
- var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, cfg.reason || "Previews are not available here.", cfg.dashboardOrigin) : "";
189
+ // #1723: a failed public-link preview names why ({0} is the reason, in the page's words).
190
+ var arg = cfg.reasonCode === "publicLinkPreviewFailed" ? say("pvFail_" + (cfg.detail || "unknown"), Object.prototype.hasOwnProperty.call(FAIL_EN, cfg.detail) ? FAIL_EN[cfg.detail] : "an unexpected error") : cfg.dashboardOrigin;
191
+ var english = cfg.reasonCode === "publicLinkPreviewFailed" ? "Previews are unavailable on this public link: {0}. The link itself keeps working."
192
+ : cfg.reason || "Previews are not available here.";
193
+ var server = cfg.reasonCode ? say("pvServer_" + cfg.reasonCode, english, arg) : "";
163
194
  return { ok: false, why: "server", reason: server || cfg.reason || say("pvWhyServer", "Previews are not available here.") };
164
195
  }
165
196
  var at = root.location && root.location.origin;
@@ -342,5 +373,7 @@
342
373
  bannerAckLabel: function () { return say("pvBannerAck", "I understand"); },
343
374
  bannerAckTitle: function () { return say("pvBannerAckTitle", "Hide this notice until you sign in again"); },
344
375
  bannerAcked: bannerAcked, ackBanner: ackBanner, setBannerScope: setBannerScope,
376
+ /** #1723: the public link's preview, while it starts (preview-status.js asks; this only takes the answer). */
377
+ waitingForPublicPreview: waitingForPublicPreview, applyPublicPreviewStatus: applyPublicPreviewStatus,
345
378
  };
346
379
  });
@@ -62,6 +62,8 @@ register("app", {
62
62
  en: {
63
63
  brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet overview", view: "View", settings: "Settings", instances: "Instances", newInstance: "New instance",
64
64
  collapse: "Hide the sidebar", expand: "Show the sidebar", openMenu: "Open the sidebar", closeMenu: "Close the sidebar",
65
+ resizeSidebar: "Sidebar width", resizeSidebarHint: "Drag to resize · double-click to reset",
66
+ names: "Names", namesNickname: "Nickname first", namesInstance: "Real name first",
65
67
  theme: "Theme", themeSystem: "System", themeLight: "Light", themeDark: "Dark", language: "Language",
66
68
  previews: "Allow HTML previews", tour: "Tour", tourTitle: "Show the web chat tour again",
67
69
  needsYou: "needs you", approxNote: "Read from the terminal, so approximate", chat: "Chat", details: "Details", instanceViews: "Views of {0}",
@@ -91,6 +93,8 @@ register("app", {
91
93
  "zh-TW": {
92
94
  brand: "AgEnD", fleet: "Fleet", fleetOverview: "Fleet 概覽", view: "檢視", settings: "設定", instances: "Instances", newInstance: "新增 instance",
93
95
  collapse: "隱藏側邊欄", expand: "顯示側邊欄", openMenu: "打開側邊欄", closeMenu: "關閉側邊欄",
96
+ resizeSidebar: "側邊欄寬度", resizeSidebarHint: "拖曳調整寬度 · 按兩下恢復預設",
97
+ names: "名稱", namesNickname: "暱稱優先", namesInstance: "實際名稱優先",
94
98
  theme: "主題", themeSystem: "跟隨系統", themeLight: "淺色", themeDark: "深色", language: "語言",
95
99
  previews: "允許預覽 HTML", tour: "導覽", tourTitle: "再看一次網頁聊天導覽",
96
100
  needsYou: "等你回應", approxNote: "從終端畫面判讀,僅供參考", chat: "聊天", details: "詳細資訊", instanceViews: "{0} 的檢視",
@@ -32,6 +32,7 @@ export const SYNCED = {
32
32
  "nav.filter": { ls: "agend_instance_filter", ...json },
33
33
  "nav.favorites": { ls: "agend_favorites", ...json }, // #1664
34
34
  "preview.optIn": { ls: "agend_html_preview", toLocal: (v) => (v ? "on" : null), fromLocal: (s) => (s === "on" ? true : undefined) },
35
+ "names.primary": { ls: "agend_names_primary", toLocal: (v) => (v === "instance" ? v : null), fromLocal: (s) => (s === "instance" ? s : undefined) }, // #1722
35
36
  };
36
37
  const own = (o, k) => !!o && Object.prototype.hasOwnProperty.call(o, k);
37
38
  const now = () => (typeof performance !== "undefined" ? performance.now() : Date.now());
@@ -12,8 +12,8 @@ import { chatPath, viewPath, settingsPath, routeKey, NEEDS_PATH, detailsPath, fl
12
12
  import { SessionMenu } from "./app-session.js";
13
13
  import { savePref } from "./app-prefs.js";
14
14
  import { ErrorState, Skeleton } from "./ui-states.js";
15
- import { InstanceNav, RosterNav } from "./instance-nav.js";
16
- import { UsageRail } from "./header-tools.js";
15
+ import { InstanceNav, RosterNav, namesStore, setNamesPrimary } from "./instance-nav.js";
16
+ import { UsageRail, usageRailStore } from "./header-tools.js";
17
17
 
18
18
  /** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
19
19
  export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
@@ -43,6 +43,144 @@ export function closeDialog() { shellStore.set({ dialog: null }); }
43
43
  function readCollapsed() { try { return localStorage.getItem("agend_sidebar") === "hidden"; } catch { return false; } }
44
44
  const narrow = () => typeof matchMedia === "function" && matchMedia("(max-width: 899px)").matches;
45
45
 
46
+ // ── #1722: the sidebar's width — this device's (the #1665 split: a width depends on the screen), through the CSSOM ──
47
+ export const SIDEBAR_W = { min: 200, max: 480, def: 260, step: 16, mainMin: 480 };
48
+ const SIDEBAR_W_KEY = "agend_sidebar_w";
49
+ const win = () => (typeof window !== "undefined" ? window : null);
50
+ const viewportWidth = () => { const w = win(); return w && typeof w.innerWidth === "number" && w.innerWidth > 0 ? w.innerWidth : 1366; };
51
+ /** The width the sidebar may have on a viewport `vw` px wide: within [min, max], and leaving the main pane mainMin. */
52
+ export function clampSidebarWidth(w, vw) {
53
+ const max = Math.max(SIDEBAR_W.min, Math.min(SIDEBAR_W.max, vw - SIDEBAR_W.mainMin));
54
+ return Math.round(Math.min(max, Math.max(SIDEBAR_W.min, w)));
55
+ }
56
+ function storedSidebarWidth() { try { const v = Number(localStorage.getItem(SIDEBAR_W_KEY)); return Number.isFinite(v) && v > 0 ? v : null; } catch { return null; } }
57
+ /** { width: px | null } — null: the default width (tokens.css). */
58
+ export const sidebarWidthStore = createStore({ width: null });
59
+ /** Set the width (clamped to this viewport); `save`: this device keeps it. null: back to the default, nothing kept. */
60
+ /**
61
+ * The room the main pane has beside the sidebar: the window, less the usage rail when it is on screen (#1673 — shown
62
+ * open, ≥ 1200 px; its CSS width when the layout gives none). #1722 review: the sidebar never leaves main under 480.
63
+ */
64
+ function mainBudget() {
65
+ const vw = viewportWidth();
66
+ if (vw < 1200 || typeof document === "undefined") return vw;
67
+ const rail = document.querySelector(".u-rail");
68
+ if (!rail) return vw;
69
+ const w = Math.round(rail.getBoundingClientRect ? rail.getBoundingClientRect().width : 0) || RAIL_W;
70
+ return vw - w;
71
+ }
72
+ const RAIL_W = 320;
73
+ /** Show a width (clamped to the room there is now) — this page only; null: the default width. Nothing is kept. */
74
+ function showSidebarWidth(w) {
75
+ const root = document.documentElement;
76
+ if (w == null) { root.style.removeProperty("--sidebar-w"); sidebarWidthStore.set({ width: null }); return null; }
77
+ const v = clampSidebarWidth(w, mainBudget());
78
+ root.style.setProperty("--sidebar-w", `${v}px`); // CSSOM: the page's CSP allows no style attribute
79
+ sidebarWidthStore.set({ width: v });
80
+ return v;
81
+ }
82
+ /** Set the width (clamped to the room there is now); `save`: this device keeps it. null: back to the default, nothing kept. */
83
+ export function setSidebarWidth(w, save = false) {
84
+ if (w == null) {
85
+ try { localStorage.removeItem(SIDEBAR_W_KEY); } catch { /* this page only */ }
86
+ return showSidebarWidth(null);
87
+ }
88
+ const v = showSidebarWidth(w);
89
+ // Kept: the choice within the window, not the squeeze beside the rail — it comes back when the rail goes.
90
+ if (save) { try { localStorage.setItem(SIDEBAR_W_KEY, String(clampSidebarWidth(w, viewportWidth()))); } catch { /* this page only */ } }
91
+ return v;
92
+ }
93
+ /** A drag in progress (one at a time): its end(commit). Only it may set the width until it ends. */
94
+ let activeDrag = null;
95
+ /**
96
+ * This device's kept width, shown within the room there is now — again whenever that changes (the window, the usage
97
+ * rail, the page). A squeeze is shown, never kept: the device's choice comes back when there is room. On a phone (the
98
+ * drawer) none applies, and a drag in progress is cancelled.
99
+ */
100
+ function applyKeptSidebarWidth() {
101
+ if (narrow()) {
102
+ if (activeDrag) activeDrag.end(false);
103
+ showSidebarWidth(null);
104
+ return;
105
+ }
106
+ if (activeDrag) return;
107
+ const w = storedSidebarWidth();
108
+ if (w != null) showSidebarWidth(w);
109
+ }
110
+ const afterPaint = (fn) => (typeof requestAnimationFrame === "function" ? requestAnimationFrame(() => fn()) : setTimeout(fn, 0));
111
+ /** The separator on the sidebar's right edge: drag, ← → (16 px), Home / End, Enter or a double-click resets. */
112
+ function SidebarResize() {
113
+ const { width } = useStore(sidebarWidthStore);
114
+ useStore(usageRailStore); // the rail's room is the separator's limit too
115
+ useLayoutEffect(() => {
116
+ applyKeptSidebarWidth();
117
+ // The room changes with the window, the rail (opened, closed) and the page (a page with no rail): shown again,
118
+ // once the page has drawn its change.
119
+ const again = () => afterPaint(applyKeptSidebarWidth);
120
+ const offRail = usageRailStore.subscribe(again);
121
+ const offNav = navStore.subscribe(again);
122
+ const w = win();
123
+ if (w && typeof w.addEventListener === "function") w.addEventListener("resize", applyKeptSidebarWidth);
124
+ return () => {
125
+ offRail(); offNav();
126
+ if (w && typeof w.removeEventListener === "function") w.removeEventListener("resize", applyKeptSidebarWidth);
127
+ if (activeDrag) activeDrag.end(false); // unmounted mid-drag: nothing kept, nothing left behind
128
+ };
129
+ }, []);
130
+ const now = width ?? SIDEBAR_W.def;
131
+ const onKey = (e) => {
132
+ const cur = sidebarWidthStore.get().width ?? SIDEBAR_W.def;
133
+ if (e.key === "ArrowRight") setSidebarWidth(cur + SIDEBAR_W.step, true);
134
+ else if (e.key === "ArrowLeft") setSidebarWidth(cur - SIDEBAR_W.step, true);
135
+ else if (e.key === "Home") setSidebarWidth(SIDEBAR_W.min, true);
136
+ else if (e.key === "End") setSidebarWidth(SIDEBAR_W.max, true);
137
+ else if (e.key === "Enter") setSidebarWidth(null);
138
+ else return;
139
+ e.preventDefault();
140
+ };
141
+ // One pointer owns a drag. Only its release keeps the width; a cancel, a lost capture, the drawer or an unmount
142
+ // puts back the width shown before it and keeps nothing — and no event of it can write after it ended. What is kept
143
+ // is where the pointer went (within the window), not what the room beside the rail let it show.
144
+ const onDown = (e) => {
145
+ if (e.button !== 0 || activeDrag || narrow()) return;
146
+ e.preventDefault();
147
+ const handle = e.currentTarget, pointerId = e.pointerId;
148
+ const before = sidebarWidthStore.get().width;
149
+ let chosen = null; // the pointer's own width this drag; null: it never moved
150
+ const left = handle.parentElement ? handle.parentElement.getBoundingClientRect().left : 0;
151
+ const mine = {};
152
+ const own = (ev) => activeDrag === mine && (ev.pointerId === undefined || ev.pointerId === pointerId);
153
+ const move = (ev) => { if (own(ev)) { chosen = ev.clientX - left; showSidebarWidth(chosen); } };
154
+ const up = (ev) => { if (own(ev)) mine.end(true); };
155
+ const cancel = (ev) => { if (own(ev)) mine.end(false); };
156
+ mine.end = (commit) => {
157
+ if (activeDrag !== mine) return;
158
+ activeDrag = null;
159
+ handle.removeEventListener("pointermove", move);
160
+ handle.removeEventListener("pointerup", up);
161
+ handle.removeEventListener("pointercancel", cancel);
162
+ handle.removeEventListener("lostpointercapture", cancel);
163
+ handle.classList.remove("dragging");
164
+ document.body.classList.remove("side-resizing");
165
+ try { handle.releasePointerCapture(pointerId); } catch { /* not held */ }
166
+ if (commit && chosen != null) setSidebarWidth(chosen, true);
167
+ else showSidebarWidth(before);
168
+ };
169
+ activeDrag = mine;
170
+ try { handle.setPointerCapture(pointerId); } catch { /* the move still works while over the handle */ }
171
+ handle.classList.add("dragging");
172
+ document.body.classList.add("side-resizing");
173
+ handle.addEventListener("pointermove", move);
174
+ handle.addEventListener("pointerup", up);
175
+ handle.addEventListener("pointercancel", cancel);
176
+ handle.addEventListener("lostpointercapture", cancel);
177
+ };
178
+ return html`<div class="side-resize" role="separator" aria-orientation="vertical" tabindex="0" aria-controls="sidebar"
179
+ aria-label=${t("app.resizeSidebar")} title=${t("app.resizeSidebarHint")} aria-valuemin=${String(SIDEBAR_W.min)}
180
+ aria-valuemax=${String(clampSidebarWidth(Infinity, mainBudget()))} aria-valuenow=${String(now)}
181
+ onKeyDown=${onKey} onPointerDown=${onDown} ondblclick=${() => setSidebarWidth(null)}></div>`;
182
+ }
183
+
46
184
  export function openDrawer() { shellStore.set({ drawer: true }); }
47
185
  export function closeDrawer() { if (shellStore.get().drawer) shellStore.set({ drawer: false }); }
48
186
  export function toggleSidebar() {
@@ -127,6 +265,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
127
265
  <a class="side-row" href=${signInHref()}><${Icon} name="user" /><span>${t("app.signIn")}</span></a>
128
266
  <${PrefsPopover} />
129
267
  </div>
268
+ <${SidebarResize} />
130
269
  </aside>`;
131
270
  }
132
271
  // #1675: the whole fleet, on its Org chart (#1389), from anywhere — top left, so at the top of a phone's drawer too. The
@@ -152,6 +291,7 @@ function Sidebar({ route, onNewInstance, viewOnly }) {
152
291
  ${navLink("settings", settingsPath(), "settings", t("app.settings"))}
153
292
  <${SessionMenu} />
154
293
  </div>
294
+ <${SidebarResize} />
155
295
  </aside>`;
156
296
  }
157
297
 
@@ -167,6 +307,7 @@ function ViewOnlyNav({ route }) {
167
307
  export function DevicePrefs({ labels = false } = {}) {
168
308
  const [theme, setTheme] = useState(typeof AgendTheme !== "undefined" ? AgendTheme.get() : "system");
169
309
  const [l, setL] = useState(lang());
310
+ const names = useStore(namesStore); // #1722
170
311
  // #1665: what is in force, as it changes — another device's choice arriving while this is open is shown at once.
171
312
  useEffect(() => (typeof AgendTheme !== "undefined" && typeof AgendTheme.onChange === "function" ? AgendTheme.onChange(setTheme) : undefined), []);
172
313
  useEffect(() => onLang(setL), []);
@@ -177,6 +318,9 @@ export function DevicePrefs({ labels = false } = {}) {
177
318
  <label class="pref"><${Icon} name="globe" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.language")}</span>
178
319
  <select value=${l} aria-label=${t("app.language")} onChange=${(e) => { setL(e.target.value); setLang(e.target.value); savePref("lang", e.target.value); }}>
179
320
  <option value="en">English</option><option value="zh-TW">中文</option></select></label>
321
+ <label class="pref"><${Icon} name="user" size=${16} /><span class=${labels ? "pref-label" : "sr-only"}>${t("app.names")}</span>
322
+ <select value=${names.primary} aria-label=${t("app.names")} onChange=${(e) => setNamesPrimary(e.target.value)}>
323
+ <option value="nickname">${t("app.namesNickname")}</option><option value="instance">${t("app.namesInstance")}</option></select></label>
180
324
  </div>`;
181
325
  }
182
326