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

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 (131) hide show
  1. package/dist/agent-cli.js +6 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/auth-api.js +2 -0
  4. package/dist/auth-api.js.map +1 -1
  5. package/dist/backend/claude-code.d.ts +39 -2
  6. package/dist/backend/claude-code.js +115 -31
  7. package/dist/backend/claude-code.js.map +1 -1
  8. package/dist/backend/credential-profile.d.ts +33 -3
  9. package/dist/backend/credential-profile.js +107 -5
  10. package/dist/backend/credential-profile.js.map +1 -1
  11. package/dist/backend/types.d.ts +1 -1
  12. package/dist/cache-service.d.ts +2 -0
  13. package/dist/cache-service.js +2 -1
  14. package/dist/cache-service.js.map +1 -1
  15. package/dist/channel/adapters/discord.d.ts +14 -1
  16. package/dist/channel/adapters/discord.js +42 -0
  17. package/dist/channel/adapters/discord.js.map +1 -1
  18. package/dist/channel/adapters/telegram.d.ts +18 -3
  19. package/dist/channel/adapters/telegram.js +66 -10
  20. package/dist/channel/adapters/telegram.js.map +1 -1
  21. package/dist/channel/telegram-markdown.d.ts +6 -0
  22. package/dist/channel/telegram-markdown.js +104 -0
  23. package/dist/channel/telegram-markdown.js.map +1 -0
  24. package/dist/channel/tool-router.js +5 -4
  25. package/dist/channel/tool-router.js.map +1 -1
  26. package/dist/channel/types.d.ts +27 -4
  27. package/dist/config-validator.js +10 -5
  28. package/dist/config-validator.js.map +1 -1
  29. package/dist/daemon.d.ts +108 -0
  30. package/dist/daemon.js +432 -32
  31. package/dist/daemon.js.map +1 -1
  32. package/dist/delivery-outbox.d.ts +29 -7
  33. package/dist/delivery-outbox.js +134 -16
  34. package/dist/delivery-outbox.js.map +1 -1
  35. package/dist/delivery-reconciliation.d.ts +18 -1
  36. package/dist/delivery-reconciliation.js +67 -2
  37. package/dist/delivery-reconciliation.js.map +1 -1
  38. package/dist/fleet-context.d.ts +1 -0
  39. package/dist/fleet-manager.d.ts +81 -4
  40. package/dist/fleet-manager.js +452 -85
  41. package/dist/fleet-manager.js.map +1 -1
  42. package/dist/general-knowledge/skills/credential-profiles/SKILL.md +43 -10
  43. package/dist/host-memory.d.ts +10 -0
  44. package/dist/host-memory.js +33 -1
  45. package/dist/host-memory.js.map +1 -1
  46. package/dist/instance-lifecycle.d.ts +25 -6
  47. package/dist/instance-lifecycle.js +73 -26
  48. package/dist/instance-lifecycle.js.map +1 -1
  49. package/dist/locale.js +48 -18
  50. package/dist/locale.js.map +1 -1
  51. package/dist/login-controller.d.ts +20 -3
  52. package/dist/login-controller.js +71 -19
  53. package/dist/login-controller.js.map +1 -1
  54. package/dist/login-flows.d.ts +5 -2
  55. package/dist/login-flows.js +5 -4
  56. package/dist/login-flows.js.map +1 -1
  57. package/dist/memory-pressure.d.ts +13 -1
  58. package/dist/memory-pressure.js +50 -10
  59. package/dist/memory-pressure.js.map +1 -1
  60. package/dist/outbound-handlers.d.ts +63 -0
  61. package/dist/outbound-handlers.js +121 -46
  62. package/dist/outbound-handlers.js.map +1 -1
  63. package/dist/outbound-schemas.d.ts +3 -2
  64. package/dist/outbound-schemas.js +3 -1
  65. package/dist/outbound-schemas.js.map +1 -1
  66. package/dist/public-web-gateway.js +8 -5
  67. package/dist/public-web-gateway.js.map +1 -1
  68. package/dist/settings-api.js +10 -4
  69. package/dist/settings-api.js.map +1 -1
  70. package/dist/settings-change.js +2 -2
  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/tips.js +1 -1
  77. package/dist/tips.js.map +1 -1
  78. package/dist/topic-commands.js +9 -2
  79. package/dist/topic-commands.js.map +1 -1
  80. package/dist/transcript-delta-reader.d.ts +3 -3
  81. package/dist/transcript-delta-reader.js +8 -8
  82. package/dist/transcript-delta-reader.js.map +1 -1
  83. package/dist/turn-resume.d.ts +2 -0
  84. package/dist/turn-resume.js +1 -1
  85. package/dist/turn-resume.js.map +1 -1
  86. package/dist/types.d.ts +2 -0
  87. package/dist/ui/chat-mentions.js +63 -0
  88. package/dist/ui/chat-render.js +25 -0
  89. package/dist/ui/chat-store.js +1 -1
  90. package/dist/ui/chat-strings.js +12 -12
  91. package/dist/ui/chat-thread.js +48 -11
  92. package/dist/ui/panel-chat.js +20 -9
  93. package/dist/ui/panel-details.js +2 -0
  94. package/dist/ui/panel-fleet.js +51 -22
  95. package/dist/ui/panel-settings.js +18 -9
  96. package/dist/ui/preview-panel.js +48 -14
  97. package/dist/ui/preview.js +5 -1
  98. package/dist/ui/settings-strings.js +6 -2
  99. package/dist/ui/settings-token.js +1 -1
  100. package/dist/ui/shared/app-i18n.js +35 -13
  101. package/dist/ui/shared/app-prefs.js +249 -0
  102. package/dist/ui/shared/app-shell.js +17 -4
  103. package/dist/ui/shared/app-store.js +5 -1
  104. package/dist/ui/shared/app.css +41 -7
  105. package/dist/ui/shared/app.js +6 -1
  106. package/dist/ui/shared/header-tools.js +55 -5
  107. package/dist/ui/shared/instance-nav.js +131 -38
  108. package/dist/ui/shared/panel-view.js +6 -0
  109. package/dist/ui/shared/theme.js +39 -7
  110. package/dist/ui/shared/ui-icons.js +1 -0
  111. package/dist/ui/shared/usage-dialog.js +13 -4
  112. package/dist/ui/shared/view-strings.js +26 -2
  113. package/dist/unrecognised-prompt.d.ts +23 -0
  114. package/dist/unrecognised-prompt.js +74 -0
  115. package/dist/unrecognised-prompt.js.map +1 -0
  116. package/dist/view-api.js +5 -0
  117. package/dist/view-api.js.map +1 -1
  118. package/dist/web-api.d.ts +13 -1
  119. package/dist/web-api.js +218 -18
  120. package/dist/web-api.js.map +1 -1
  121. package/dist/web-auth.js +3 -2
  122. package/dist/web-auth.js.map +1 -1
  123. package/dist/web-mentions.d.ts +44 -0
  124. package/dist/web-mentions.js +75 -0
  125. package/dist/web-mentions.js.map +1 -0
  126. package/dist/web-prefs.d.ts +63 -0
  127. package/dist/web-prefs.js +263 -0
  128. package/dist/web-prefs.js.map +1 -0
  129. package/dist/web-session.js +1 -1
  130. package/dist/web-session.js.map +1 -1
  131. package/package.json +1 -1
@@ -59,6 +59,21 @@
59
59
  return '<img class="emoji" src="/ui/img/emoji/' + id + '" alt=":' + name + ':" title=":' + name + ':" decoding="async">';
60
60
  }
61
61
 
62
+ // #1682: Discord mentions — `<@id>` / `<@!id>` (a user), `<@&id>` (a role), `<#id>` (a channel) — escaped by now (the
63
+ // role's & too), drawn as a pill: the name the page knows (store.mentions(kind, id), inserted escaped), else @user /
64
+ // @role / #channel; never the raw token. The id is the token's own digits (title, data-mention: the page fills a
65
+ // name in later). And a Telegram @username (a letter, then 4–31 word characters; not part of a word, an e-mail
66
+ // or a path) gets the same look — no lookup, no link. It runs after the links (see inline()).
67
+ var MENTION_RE = /&lt;(@!?|@&amp;|#)(\d{15,25})&gt;/g;
68
+ // Not a package or a path either: `@scope/pkg`, `@anthropic-ai/sdk` (followed by - or /) stay text.
69
+ var TG_MENTION_RE = /(^|[^\w@\/.&;])@([A-Za-z][A-Za-z0-9_]{4,31})(?![\w@\/-])/g;
70
+ var MENTION_KIND = { "@": "u", "@!": "u", "@&amp;": "r", "#": "c" };
71
+ var MENTION_UNKNOWN = { u: "@user", r: "@role", c: "#channel" };
72
+ function mentionPill(kind, id, name) {
73
+ var label = name ? (kind === "c" ? "#" : "@") + escapeHtml(name) : MENTION_UNKNOWN[kind];
74
+ return '<span class="mention" data-mention="' + kind + ":" + id + '" title="' + id + '">' + label + "</span>";
75
+ }
76
+
62
77
  /** Inline Markdown over ALREADY-ESCAPED text. */
63
78
  function inline(escaped, store) {
64
79
  var s = escaped;
@@ -66,6 +81,12 @@
66
81
  s = s.replace(/`([^`\n]+)`/g, function (_m, code) { return stash(store, "<code>" + code + "</code>"); });
67
82
  var known = store.emojis;
68
83
  if (known) s = s.replace(EMOJI_RE, function (m, _a, name, id) { return known(id) ? stash(store, emojiImg(id, name)) : m; });
84
+ var names = store.mentions;
85
+ s = s.replace(MENTION_RE, function (_m, sigil, id) {
86
+ var kind = MENTION_KIND[sigil];
87
+ var name = names ? names(kind, id) : null;
88
+ return stash(store, mentionPill(kind, id, typeof name === "string" && name ? name : null));
89
+ });
69
90
  // [label](url)
70
91
  s = s.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, function (m, label, url) {
71
92
  var href = safeHref(url);
@@ -76,6 +97,8 @@
76
97
  var href = safeHref(url);
77
98
  return href ? pre + stash(store, link(href, url)) : m;
78
99
  });
100
+ // #1682: a Telegram @username — after the links (a link is stashed by now, so an @ inside a URL is never touched).
101
+ s = s.replace(TG_MENTION_RE, function (_m, pre, user) { return pre + stash(store, '<span class="mention tg">@' + user + "</span>"); });
79
102
  return emphasis(s);
80
103
  }
81
104
 
@@ -285,6 +308,8 @@
285
308
  var store = [];
286
309
  // #1630: which server emoji ids the page knows (a function id → boolean); without it every `<:name:id>` stays text.
287
310
  store.emojis = opts && typeof opts.emojis === "function" ? opts.emojis : null;
311
+ // #1682: the names the page knows for mentions (a function (kind "u" | "r" | "c", id) → name or null).
312
+ store.mentions = opts && typeof opts.mentions === "function" ? opts.mentions : null;
288
313
  var html = "";
289
314
  var re = new RegExp(FENCE_RE.source, "g");
290
315
  var last = 0;
@@ -66,7 +66,7 @@ export function createChatStore(deps) {
66
66
  if (!m || typeof m.instance !== "string") return;
67
67
  const before = s.msgs[m.instance];
68
68
  s.msgs[m.instance] = R().mergeMessages(before, [m], 500);
69
- if (m.instance === s.current && s.msgs[m.instance] !== before && !isUser(m, m.instance) && (s.msgs[m.instance] || []).length > (before || []).length) deps.announce(tf("chat.a11yReplied", m.sender));
69
+ if (m.instance === s.current && s.msgs[m.instance] !== before && m.role !== "status" && !isUser(m, m.instance) && (s.msgs[m.instance] || []).length > (before || []).length) deps.announce(tf("chat.a11yReplied", m.sender));
70
70
  if (s.msgs[m.instance] !== before) changed(m.instance, "msgs");
71
71
  }
72
72
  function applyDeliveries(list) {
@@ -21,8 +21,8 @@ register("chat", {
21
21
  dropHere: "Drop files to attach them", asText: "As text", pasteAttached: "A long paste ({0} characters) was attached as a text file", pasteKeptAsText: "No room for another attachment: the long paste stays as text",
22
22
  send: "Send", composerPlaceholder: "Message {0}…", composerHint: "Enter to send · Shift+Enter for a new line · / for commands · Esc stops the reply", attachTitle: "Attach files (images, PDF, text — or paste / drop them)",
23
23
  pvPreview: "Preview", pvStop: "Stop", pvDownload: "Download", pvMenu: "Preview options", pvHtml: "HTML",
24
- pvAllowButton: "Allow previews", pvAllow: "Allow HTML previews on this device — they may be able to send data out", pvDisallow: "Stop allowing HTML previews on this device",
25
- pvConfirm: "Allow HTML previews on this device?\n\nA preview runs the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out, and it can slow or freeze this tab. You still click Preview each time.",
24
+ pvAllowButton: "Allow previews", pvAllow: "Allow HTML previews — they may be able to send data out", pvDisallow: "Stop allowing HTML previews",
25
+ pvConfirm: "Allow HTML previews?\n\nThis applies on every device where you are signed in. A preview runs the agent's HTML in an isolated frame. It cannot use your login, but it may be able to send data out, and it can slow or freeze this tab. You still click Preview each time.",
26
26
  pvNever: "Never preview HTML on this device", pvNeverUndo: "Let this device preview HTML again", pvStarting: "Starting the preview…",
27
27
  pvChanged: "Preview stopped — the message changed. Preview again.", pvTruncated: "Truncated — ask the agent to send it as a .html file.",
28
28
  pvLoading: "Reading the file…", pvAttOver: "This file is over 1 MiB; it is not previewed.",
@@ -37,8 +37,8 @@ register("chat", {
37
37
  pvNewer: "A newer version is in the reply from {0}.", pvNewerShow: "Show it", pvPanelIdle: "Click Preview to run it here.",
38
38
  lbLabel: "Image: {0}", lbLabelNoName: "Image", lbClose: "Close", lbOpenOriginal: "Open original", lbPrev: "Previous image", lbNext: "Next image", lbCount: "{0} / {1}",
39
39
  // #1554: preview.js's words (its English is the fallback there), and the server's reasons by code.
40
- pvWhyOptin: "HTML previews are off on this device (this browser, at this address). Allow them from this card's ⋯ menu or the sidebar.",
41
- pvWhyOptinPanel: "HTML previews are off on this device (this browser, at this address).",
40
+ pvWhyOptin: "HTML previews are off. Allow them from this card's ⋯ menu or Settings → Preferences.",
41
+ pvWhyOptinPanel: "HTML previews are off.",
42
42
  pvWhyNever: "Previews are switched off on this device for this session.",
43
43
  pvWhyOrigin: "Previews are off: this page is at {0}, but the fleet believes it is at {1} (a proxy that rewrites Host). Pass the external Host through, or set web.preview_origin.",
44
44
  pvUnknownAddress: "an unknown address", pvWhyServer: "Previews are not available here.",
@@ -55,8 +55,8 @@ register("chat", {
55
55
  pvServer_sameOrigin: "The preview origin cannot be the dashboard's own.",
56
56
  pvServer_publicLink: "Previews are not offered over the public link. Open the dashboard on the machine running AgEnD to preview.",
57
57
  pvServer_notOffered: "Previews are not available on this page.",
58
- previews: "Allow HTML previews on this device",
59
- copy: "Copy", copyMessage: "Copy this reply", copied: "Copied", copyFailed: "Copy failed", wrap: "Wrap", showAll: "Show all {0} lines", showLess: "Show less", jumpLatest: "Latest", newBelow: "{0} new",
58
+ previews: "Allow HTML previews",
59
+ copy: "Copy", copyMessage: "Copy this reply", copied: "Copied", copyFailed: "Copy failed", wrap: "Wrap", showAll: "Show all {0} lines", showMore: "Show more", showLess: "Show less", jumpLatest: "Latest", newBelow: "{0} new",
60
60
  disconnected: "disconnected", tick_queued: "Waiting behind another message", tick_processing: "Handed to the agent", tick_delivered: "The agent has it", tick_failed: "Not delivered", tick_cancelled: "Dropped by Stop",
61
61
  promptAnswering: "Answering…", promptGone: "This prompt is no longer open", promptFor: "{0}: {1}",
62
62
  working: "{0} is working…", stuck: "{0} looks stuck", stopReply: "Stop reply", stopReplyTitle: "Stop the current reply (like Esc) and drop the messages still waiting. The instance keeps running.", stopSent: "Stopped",
@@ -96,8 +96,8 @@ register("chat", {
96
96
  dropHere: "放開即可附加檔案", asText: "改為文字", pasteAttached: "貼上的長文字({0} 個字元)已改為文字檔附加", pasteKeptAsText: "附件已滿,貼上的長文字保留為一般文字",
97
97
  send: "送出", composerPlaceholder: "傳訊息給 {0}…", composerHint: "Enter 送出 · Shift+Enter 換行 · / 叫出指令 · Esc 中止回覆", attachTitle: "附加檔案(圖片、PDF、文字檔,也可以貼上或拖曳)",
98
98
  pvPreview: "預覽", pvStop: "停止", pvDownload: "下載", pvMenu: "預覽選項", pvHtml: "HTML",
99
- pvAllowButton: "允許預覽", pvAllow: "允許這台裝置預覽 HTML(預覽可能把資料傳出去)", pvDisallow: "不再允許這台裝置預覽 HTML",
100
- pvConfirm: "要允許這台裝置預覽 HTML 嗎?\n\n預覽會在隔離的框架裡執行 agent 的 HTML。它無法使用你的登入,但可能把資料傳出去,也可能讓這個分頁變慢或卡住。每次仍需要你按「預覽」。",
99
+ pvAllowButton: "允許預覽", pvAllow: "允許預覽 HTML(預覽可能把資料傳出去)", pvDisallow: "不再允許預覽 HTML",
100
+ pvConfirm: "要允許預覽 HTML 嗎?\n\n這會套用到你登入的每一台裝置。預覽會在隔離的框架裡執行 agent 的 HTML。它無法使用你的登入,但可能把資料傳出去,也可能讓這個分頁變慢或卡住。每次仍需要你按「預覽」。",
101
101
  pvNever: "這台裝置永不預覽 HTML", pvNeverUndo: "讓這台裝置再次可以預覽 HTML", pvStarting: "正在啟動預覽…",
102
102
  pvChanged: "預覽已停止——訊息內容變了。請再預覽一次。", pvTruncated: "內容被截斷——請 agent 改用 .html 檔案傳送。",
103
103
  pvLoading: "正在讀取檔案…", pvAttOver: "這個檔案超過 1 MiB,不提供預覽。",
@@ -111,8 +111,8 @@ register("chat", {
111
111
  pvPage: "頁面", pvCode: "程式碼", pvReload: "重新載入", pvClose: "關閉面板", pvBack: "回到對話", pvResize: "調整預覽面板寬度",
112
112
  pvNewer: "{0} 的回覆裡有較新的版本。", pvNewerShow: "顯示", pvPanelIdle: "按「預覽」在這裡執行。",
113
113
  lbLabel: "圖片:{0}", lbLabelNoName: "圖片", lbClose: "關閉", lbOpenOriginal: "開啟原圖", lbPrev: "上一張", lbNext: "下一張", lbCount: "{0} / {1}",
114
- pvWhyOptin: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。可從這張卡片的 ⋯ 選單或側欄允許。",
115
- pvWhyOptinPanel: "這台裝置(這個瀏覽器、這個網址)還沒允許 HTML 預覽。",
114
+ pvWhyOptin: "還沒允許 HTML 預覽。可從這張卡片的 ⋯ 選單或「設定 → 偏好設定」允許。",
115
+ pvWhyOptinPanel: "還沒允許 HTML 預覽。",
116
116
  pvWhyNever: "這台裝置在這個工作階段已關閉預覽。",
117
117
  pvWhyOrigin: "預覽已關閉:這個頁面位於 {0},但艦隊認為它位於 {1}(有會改寫 Host 的代理)。請讓外部 Host 原樣傳入,或設定 web.preview_origin。",
118
118
  pvUnknownAddress: "未知的網址", pvWhyServer: "這裡無法使用預覽。",
@@ -129,8 +129,8 @@ register("chat", {
129
129
  pvServer_sameOrigin: "預覽的來源不能和儀表板相同。",
130
130
  pvServer_publicLink: "公開連結不提供預覽。要預覽,請在執行 AgEnD 的那台機器上開啟儀表板。",
131
131
  pvServer_notOffered: "這個頁面不提供預覽。",
132
- previews: "允許這台裝置預覽 HTML",
133
- copy: "複製", copyMessage: "複製這則回覆", copied: "已複製", copyFailed: "複製失敗", wrap: "換行", showAll: "展開全部 {0} 行", showLess: "收合", jumpLatest: "最新", newBelow: "{0} 則新訊息",
132
+ previews: "允許預覽 HTML",
133
+ copy: "複製", copyMessage: "複製這則回覆", copied: "已複製", copyFailed: "複製失敗", wrap: "換行", showAll: "展開全部 {0} 行", showMore: "顯示更多", showLess: "收合", jumpLatest: "最新", newBelow: "{0} 則新訊息",
134
134
  disconnected: "已斷線", tick_queued: "排在前一則訊息之後", tick_processing: "已交給 agent", tick_delivered: "agent 已收到", tick_failed: "未送達", tick_cancelled: "已被中止取消",
135
135
  promptAnswering: "回覆中…", promptGone: "這個提示已經結束", promptFor: "{0}:{1}",
136
136
  working: "{0} 處理中…", stuck: "{0} 似乎卡住了", stopReply: "中止回覆", stopReplyTitle: "中止目前的回覆(同 Esc),並取消還在排隊的訊息。instance 會繼續運行。", stopSent: "已中止",
@@ -22,6 +22,9 @@ export const TEXT_PREVIEW_NODES = 4000;
22
22
  /** A csv/tsv wider than this is shown as text, as Markdown tables are capped at 20 columns. */
23
23
  export const TEXT_PREVIEW_COLUMNS = 50;
24
24
  const P = () => globalThis.AgendPreview;
25
+ /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
26
+ export const STATUS_FOLD_LINES = 6;
27
+ export const STATUS_FOLD_CHARS = 600;
25
28
  const COPY = '<svg class="icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
26
29
 
27
30
  /** Escapes & " ' < >: safe for text and inside a quoted attribute. */
@@ -31,7 +34,7 @@ export const msgKey = (x) => `${x.boot}-${x.id}`;
31
34
  /**
32
35
  * Bind a renderer to `list` (the thread element) inside `scroller`. opts: { t, tf, isUser(x), onJump({show, unseen}),
33
36
  * setPreviewOptIn(on), copyText(text) → Promise<boolean>, download(code), panel? }. panel (#1481): { shown() → the card
34
- * key the side panel shows, or null; open(spec, { run }) } — without it a card has no "Open in panel".
37
+ * key the side panel shows, or null; open(spec, { run }); version(); claim(); claims() } — without it a card has no "Open in panel".
35
38
  */
36
39
  export function createThread(list, scroller, opts) {
37
40
  const nodes = new Map(); // key → { html, node }
@@ -50,13 +53,29 @@ export function createThread(list, scroller, opts) {
50
53
  const TICKS = () => ({ queued: tr("chat.tick_queued"), processing: tr("chat.tick_processing"), delivered: tr("chat.tick_delivered"), failed: tr("chat.tick_failed"), cancelled: tr("chat.tick_cancelled") });
51
54
 
52
55
  function msgHtml(x) {
53
- const u = opts.isUser(x);
54
56
  const time = x.ts ? new Date(x.ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) : "";
57
+ if (x.role === "status") return statusHtml(x, time);
58
+ const u = opts.isUser(x);
55
59
  // renderMarkdown escapes the whole text before it adds any tag of its own (chat-render.js).
56
60
  const ticks = u && x.delivery ? R().deliveryHtml(x.delivery, TICKS()) : "";
57
61
  const tools = u ? "" : `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
58
62
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
59
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis })}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
63
+ return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span><span class="time">${time}</span>${ticks}</div><div class="body"><div class="md">${R().renderMarkdown(x.text, { htmlCards: x.role === "agent", emojis: opts.emojis, mentions: opts.mentions })}</div>${R().attachmentsHtml(x.attachments, { htmlCards: x.role === "agent", goneTitle: tr("chat.attGone"), goneLabel: tr("chat.attUnavailable"), inlinePreviews: !PUBLIC_LINK() })}${buttons}</div>${tools}</div>`;
64
+ }
65
+ /**
66
+ * #1672: a status line (another instance's message, a daemon notice) is muted, read-only plain text — never Markdown,
67
+ * a preview card, a button or a person's bubble. Checked before isUser, which reads the sender: a fleet name need not
68
+ * start with "agend". A long one is folded until it is opened.
69
+ */
70
+ function statusHtml(x, time) {
71
+ const text = String(x.text == null ? "" : x.text);
72
+ const lines = R().lineCount(text);
73
+ const manyLines = lines > STATUS_FOLD_LINES;
74
+ const more = manyLines || text.length > STATUS_FOLD_CHARS
75
+ ? `<button type="button" class="st-more" data-act="toggleFold"${manyLines ? ` data-lines="${escAttr(lines)}"` : ""}>${escAttr(manyLines ? trf("chat.showAll", lines) : tr("chat.showMore"))}</button>`
76
+ : "";
77
+ const tools = `<div class="msg-tools"><button type="button" class="chip-btn icon-only" data-act="copyMsg" data-arg="${escAttr(msgKey(x))}" title="${escAttr(tr("chat.copyMessage"))}" aria-label="${escAttr(tr("chat.copyMessage"))}">${COPY}</button></div>`;
78
+ return `<div class="msg status${more ? " folded" : ""}"><div class="meta"><span class="time">${time}</span></div><div class="body"><div class="st-text">${escAttr(text)}</div>${more}</div>${tools}</div>`;
60
79
  }
61
80
  /**
62
81
  * #1266: an agent reply's buttons. Labels are text (escaped, never Markdown); the click names the set and the index,
@@ -299,14 +318,32 @@ export function createThread(list, scroller, opts) {
299
318
  };
300
319
  const ui = { state: (name, reason) => { state = name === "starting" || name === "running" ? name : "idle"; refresh(reason || null); } };
301
320
  // Nothing is read for Preview on a device that does not allow previews: the button is not even shown there.
302
- run.onclick = () => { if (P().availability().ok) withCode((c) => P().start(key, holder, c, ui)); };
321
+ // A Preview click is newer than any Open in panel still reading (#1704): it claims the panel's turn too — and, its
322
+ // own HTML read, starts only if no newer click claimed meanwhile and no preview was started since (the panel's own
323
+ // Preview): a late read never undoes a newer choice. A close is neither, so it does not drop this click.
324
+ run.onclick = () => {
325
+ if (!P().availability().ok || reading) return; // this card already reading: that click stays the one
326
+ const mine = panel ? (panel.claim(), panel.claims()) : 0, started = P().startCount();
327
+ withCode((c) => {
328
+ if ((panel && panel.claims() !== mine) || P().startCount() !== started) { refresh(); return; }
329
+ P().start(key, holder, c, ui);
330
+ });
331
+ };
303
332
  stopB.onclick = () => { if (reading) { reading = 0; refresh(); } else P().stop(key, "stopped", ""); };
304
- // A running preview moves: it stops here and starts in the panel (the click that ran it). An idle one opens the
305
- // panel with its own Preview to click.
333
+ // #1704: this click runs it in the panel, as Preview there would — a running preview moves (it stops here and
334
+ // starts there), an idle one starts there. Whether it can run is the panel's to say: a device that has not allowed
335
+ // previews is offered Allow first (and allowing runs it); where previews are off, nothing runs.
336
+ // An attachment's HTML is read first. The click claims the panel at once, so a newer click (even one still reading)
337
+ // wins: if the panel changed (another click, block, close) or any preview started meanwhile, this one is dropped —
338
+ // a late read never undoes a newer click.
306
339
  if (toPanel) toPanel.onclick = () => {
307
- const moving = P().running(key);
308
- if (moving) P().stop(key, "stopped", "");
309
- withCode((c) => panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: moving }));
340
+ if (reading) return; // this card already reading: that click stays the one
341
+ if (P().running(key)) P().stop(key, "stopped", "");
342
+ const at = panel.claim(), started = P().startCount();
343
+ withCode((c) => {
344
+ if (panel.version() !== at || P().startCount() !== started) { refresh(); return; }
345
+ panel.open({ key, instance: x.instance, msgKey: msgKey(x), ...(att ? { att } : { n: source.n }), code: c, sender: x.sender, ts: x.ts }, { run: true });
346
+ });
310
347
  };
311
348
  if (dl) dl.onclick = () => opts.download(code);
312
349
  ack.onclick = () => P().ackBanner(); // every card and the panel hear it (onChange)
@@ -426,9 +463,9 @@ export function createThread(list, scroller, opts) {
426
463
  if (act === "copyMsg") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) copyFrom(el, x.text); }
427
464
  else if (act === "copyCode") { const pre = el.closest(".codeblock")?.querySelector("pre"); if (pre) copyFrom(el, pre.textContent); }
428
465
  else if (act === "toggleFold") {
429
- const box = el.closest(".codeblock"); if (!box) return;
466
+ const box = el.closest(".codeblock, .msg.status"); if (!box) return;
430
467
  const folded = box.classList.toggle("folded");
431
- el.textContent = folded ? trf("chat.showAll", el.dataset.lines) : tr("chat.showLess");
468
+ el.textContent = !folded ? tr("chat.showLess") : el.dataset.lines ? trf("chat.showAll", el.dataset.lines) : tr("chat.showMore");
432
469
  } else if (act === "toggleWrap") opts.toggleWrap();
433
470
  else if (act === "replyButton") {
434
471
  const m = /^([0-9a-f]{32}):(\d{1,2})$/.exec(el.dataset.arg || "");
@@ -22,12 +22,14 @@ import { Empty, Skeleton } from "/assets/ui-states.js";
22
22
  import { Icon } from "/assets/ui-icons.js";
23
23
  import { toast } from "/assets/ui-toast.js";
24
24
  import { confirmDialog } from "/assets/ui-confirm.js";
25
+ import { savePref } from "/assets/app-prefs.js";
25
26
  import "./chat-strings.js";
26
27
  import { confirmedWrite } from "./settings-confirm.js";
27
28
  import { createChatStore } from "./chat-store.js";
28
29
  import { createThread, msgKey } from "./chat-thread.js";
29
30
  import { createEmojiIndex } from "./chat-emojis.js";
30
- import { PreviewPanel, openPanel, panelStore, shownKey } from "./preview-panel.js";
31
+ import { createMentionIndex } from "./chat-mentions.js";
32
+ import { PreviewPanel, claimPanel, openPanel, panelClaims, panelStore, panelVersion, shownKey } from "./preview-panel.js";
31
33
  import { needsArgument, paletteFor, parseCommandLine } from "./chat-commands.js";
32
34
  import { installTour, refreshTourSpot } from "./chat-tour.js";
33
35
  import { FirstRunCard } from "./first-run.js";
@@ -44,6 +46,7 @@ export let store = null;
44
46
  let announceTimer = null;
45
47
  /** #1630: which Discord server emoji this page may draw (created with the store, in boot). */
46
48
  let emojiIndex = null;
49
+ let mentionIndex = null; // #1682: the names of the mentions shown
47
50
  function announce(text) {
48
51
  let el = document.getElementById("announcer");
49
52
  if (!el) { el = document.createElement("div"); el.id = "announcer"; el.className = "sr-only"; el.setAttribute("role", "status"); el.setAttribute("aria-live", "polite"); el.setAttribute("aria-atomic", "true"); document.body.appendChild(el); }
@@ -55,11 +58,14 @@ function announce(text) {
55
58
 
56
59
  /**
57
60
  * This device's preview opt-in: asks once (the app's dialog) and says what it means; turning it off stops every
58
- * running preview. Resolves to the choice in force.
61
+ * running preview. Resolves to the choice in force. commit(apply): runs `apply` (the change itself) — the panel's Allow
62
+ * wraps it to know which change of the choice is its own (#1704).
59
63
  */
60
- export async function setPreviewOptIn(on) {
64
+ export async function setPreviewOptIn(on, commit = (apply) => apply()) {
61
65
  if (on && !P().optedIn() && !(await confirmDialog({ message: t("chat.pvConfirm"), confirmLabel: t("chat.pvAllowButton") }))) on = false;
62
- P().setOptIn(on);
66
+ commit(() => P().setOptIn(on));
67
+ // #1665: kept on the host for every device (only a signed-in page writes it; never the public link).
68
+ savePref("preview.optIn", P().optedIn());
63
69
  return P().optedIn();
64
70
  }
65
71
 
@@ -70,6 +76,7 @@ export function boot({ stream, boot: bootData, deps = {} }) {
70
76
  fetch: (...a) => fetch(...a), toast, announce, t, setTimeout: (fn, ms) => setTimeout(fn, ms), confirm: confirmDialog, ...deps,
71
77
  });
72
78
  emojiIndex = createEmojiIndex((url) => (deps.fetch || fetch)(url, { credentials: "same-origin" }));
79
+ mentionIndex = createMentionIndex((url) => (deps.fetch || fetch)(url, { credentials: "same-origin" }));
73
80
  store.attach(stream);
74
81
  // #1306: what the server said about previews for this load. The page-wide message and storage listeners this adds
75
82
  // belong to the app, once (they are never per mount).
@@ -194,6 +201,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
194
201
  try { const res = await fetch(`/ui/${verb}/${encodeURIComponent(name)}`, { method: "POST", headers: { "Content-Type": "application/json" } }); r = await res.json(); }
195
202
  catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
196
203
  if (r && r.error) toast(r.error, false);
204
+ else if (r && r.held) toast(r.message);
197
205
  else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
198
206
  };
199
207
  const items = [
@@ -218,7 +226,7 @@ function ChatView({ name, inst, lease, navKey, exec, awaiting }) {
218
226
  <${Dock} name=${name} inst=${inst} lease=${lease} exec=${exec} awaiting=${awaiting} />
219
227
  </div>
220
228
  <${PreviewPanel} name=${name} split=${split} msgs=${() => store.state.msgs[name] || []} subscribe=${(fn) => store.subscribe(fn)}
221
- keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} allow=${() => setPreviewOptIn(true)} />
229
+ keyOf=${msgKey} reveal=${(k) => thread.current && thread.current.reveal(k)} download=${downloadHtml} allow=${(commit) => setPreviewOptIn(true, commit)} />
222
230
  </div>
223
231
  <${LightboxHost} />
224
232
  <div class="drop-overlay" aria-hidden="true"><div class="drop-card"><${Icon} name="attach" size=${32} /><span>${t("chat.dropHere")}</span></div></div>
@@ -237,14 +245,17 @@ function Thread({ name, th }) {
237
245
  setPreviewOptIn, copyText, download: downloadHtml, toggleWrap,
238
246
  clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
239
247
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
240
- panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o) }, // #1481
248
+ panel: { shown: shownKey, open: (spec, o) => openPanel(spec, o), version: panelVersion, claim: claimPanel, claims: panelClaims }, // #1481, #1704
241
249
  openImage: openLightbox, // an image, full size, in the page
242
250
  emojis: (id) => !!emojiIndex && emojiIndex.known(id), // #1630: the fleet's server emoji
251
+ mentions: (kind, id) => (mentionIndex ? mentionIndex.name(kind, id) : null), // #1682: mention names
243
252
  });
244
253
  th.current = thread;
245
254
  // #1630: the server-emoji list is read once a message shows a tag; when it arrives, those messages are drawn again.
246
- const wantEmojis = (msgs) => { if (emojiIndex && emojiIndex.wants(msgs)) emojiIndex.ensure(); };
255
+ // #1682: the same for mention names — asked for the ids the messages carry; when names arrive, drawn again.
256
+ const wantEmojis = (msgs) => { if (emojiIndex && emojiIndex.wants(msgs)) emojiIndex.ensure(); if (mentionIndex) mentionIndex.ensure(msgs); };
247
257
  const offEmojis = emojiIndex ? emojiIndex.subscribe(() => thread.render(store.state.msgs[name] || [])) : () => {};
258
+ const offMentions = mentionIndex ? mentionIndex.subscribe(() => thread.render(store.state.msgs[name] || [])) : () => {};
248
259
  thread.render(store.state.msgs[name] || [], { restore: store.state.scrollMemo[name] ?? null });
249
260
  wantEmojis(store.state.msgs[name]);
250
261
  const off = store.subscribe((instance, kind) => {
@@ -259,7 +270,7 @@ function Thread({ name, th }) {
259
270
  sc.addEventListener("scroll", onScroll, { passive: true });
260
271
  ls.addEventListener("click", thread.onClick);
261
272
  return () => {
262
- off(); offPv(); offPanel(); offEmojis();
273
+ off(); offPv(); offPanel(); offEmojis(); offMentions();
263
274
  sc.removeEventListener("scroll", onScroll);
264
275
  ls.removeEventListener("click", thread.onClick);
265
276
  thread.dispose(); // the whole thread goes: every preview in it stops first
@@ -476,7 +487,7 @@ function DeleteDialog({ name, onClose }) {
476
487
  <button type="button" class="btn btn-danger" disabled=${typed !== want || busy} onClick=${go}>${t("chat.deleteGo")}</button>`}>
477
488
  <p>${t("chat.deleteHelp", name)}</p>
478
489
  <label class="field"><span class="sr-only">${t("chat.deleteHelp", name)}</span>
479
- <input type="text" value=${typed} autocomplete="off" spellcheck="false" placeholder=${want} onInput=${(e) => setTyped(e.target.value)}
490
+ <input type="text" value=${typed} autocomplete="off" spellcheck=${false} placeholder=${want} onInput=${(e) => setTyped(e.target.value)}
480
491
  onKeyDown=${(e) => { if (e.key === "Enter") go(); }} /></label>
481
492
  </${Dialog}>`;
482
493
  }
@@ -73,11 +73,13 @@ function DetailsView({ name, inst, navKey, exec, awaiting }) {
73
73
  try { const res = await fetch(`/ui/${verb}/${encodeURIComponent(name)}`, { method: "POST", headers: { "Content-Type": "application/json" } }); r = await res.json(); }
74
74
  catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
75
75
  if (r && r.error) toast(r.error, false);
76
+ else if (r && r.held) toast(r.message);
76
77
  else toast(t(verb === "start" ? "chat.started" : verb === "stop" ? "chat.stoppedInst" : "chat.restarted", name));
77
78
  });
78
79
  const pauseWake = (verb) => act(verb, async () => {
79
80
  const res = await api(`/api/settings/instances/${encodeURIComponent(name)}/${verb}`, { method: "POST" }).catch(() => ({ ok: false, body: {} }));
80
81
  if (!res.ok) toast((res.body && res.body.error) || t("chat.dActionFailed"), false);
82
+ else if (res.body && res.body.held) toast(res.body.message);
81
83
  else toast(t(verb === "pause" ? "chat.dPaused" : "chat.dWoken", name));
82
84
  });
83
85
  // A ClassicBot room (classicBot.yaml) keeps Classic's split: its own Settings dialog, and only the actions Settings'
@@ -23,6 +23,8 @@ import { CacheReport } from "./fleet-cache.js";
23
23
 
24
24
  register("fleet", {
25
25
  en: {
26
+ hostMemory_elevated: "Host memory pressure: elevated — new agents start one at a time, at least 5 s apart",
27
+ hostMemory_critical: "Host memory pressure: critical — new agents wait for memory to recover",
26
28
  title: "Fleet", tasks: "Tasks", schedules: "Schedules", teams: "Teams", org: "Org chart", cache: "Cache", config: "Config",
27
29
  newTask: "New task", newSchedule: "New schedule", newTeam: "New team",
28
30
  noTasks: "No tasks yet", noTasksHint: "Create one to coordinate fleet work.", noSchedules: "No schedules yet", noSchedulesHint: "Automate recurring work with cron.",
@@ -33,7 +35,8 @@ register("fleet", {
33
35
  cron: "Cron expression", message: "Message", target: "Target instance", label: "Label", teamName: "Team name", members: "Members",
34
36
  titleRequired: "Title required", scheduleFieldsRequired: "Cron, message, and target required", teamFieldsRequired: "Name and at least one member required",
35
37
  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)",
38
+ teamCreated: "Team created", teamDeleted: "Team deleted", teamUpdated: "Team updated", edit: "Edit", editTeam: "Edit team",
39
+ 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
40
  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
41
  allowedUsers: "Allowed users", allowedUsersNote: "Comma-separated user IDs", defaults: "Defaults", backend: "Backend", roots: "Project roots", addRoot: "Add root", removeRoot: "Remove",
39
42
  saveNote: "Saving rewrites fleet.yaml (its comments are lost).",
@@ -43,6 +46,8 @@ register("fleet", {
43
46
  fleetDefault: "Use the fleet default", notInstalled: "(not installed)", deprecated: "(deprecated)", instanceCreated: "Instance created",
44
47
  },
45
48
  "zh-TW": {
49
+ hostMemory_elevated: "主機記憶體壓力偏高:新的 agent 一次啟動一個,至少間隔 5 秒",
50
+ hostMemory_critical: "主機記憶體壓力嚴重:新的 agent 會等記憶體恢復再啟動",
46
51
  title: "Fleet", tasks: "Tasks", schedules: "排程", teams: "Teams", org: "組織圖", cache: "快取", config: "設定",
47
52
  newTask: "新增 task", newSchedule: "新增排程", newTeam: "新增 team",
48
53
  noTasks: "還沒有 task", noTasksHint: "建立一個來協調 fleet 的工作。", noSchedules: "還沒有排程", noSchedulesHint: "用 cron 自動執行例行工作。",
@@ -53,7 +58,8 @@ register("fleet", {
53
58
  cron: "Cron 表示式", message: "訊息", target: "目標 instance", label: "標籤", teamName: "Team 名稱", members: "成員",
54
59
  titleRequired: "必須填寫標題", scheduleFieldsRequired: "必須填寫 Cron、訊息與目標 instance", teamFieldsRequired: "必須填寫名稱並選擇至少一名成員",
55
60
  deleteSchedule: "確定刪除此排程嗎?", deleteTeam: "確定刪除 team「{0}」嗎?", taskCreated: "Task 已建立", scheduleCreated: "排程已建立", scheduleDeleted: "排程已刪除",
56
- teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", taskDone: "Task 已更新", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
61
+ teamCreated: "Team 已建立", teamDeleted: "Team 已刪除", teamUpdated: "Team 已更新", edit: "編輯", editTeam: "編輯 team",
62
+ teamNoRename: "不支援重新命名:請建立新的 team,再刪除這個。", taskDone: "Task 已更新", configSaved: "設定已儲存", configRestart: "(請重新啟動 fleet 以套用頻道變更)",
57
63
  channel: "頻道", type: "類型", tokenEnv: "Bot token 環境變數", groupId: "Group ID", show: "顯示", hide: "隱藏", access: "存取控制", mode: "模式", locked: "鎖定", open: "開放",
58
64
  allowedUsers: "允許的使用者", allowedUsersNote: "以逗號分隔的使用者 ID", defaults: "預設值", backend: "Backend", roots: "專案根目錄", addRoot: "新增根目錄", removeRoot: "移除",
59
65
  saveNote: "儲存會改寫 fleet.yaml(其中的註解會遺失)。",
@@ -104,8 +110,11 @@ export function FleetPanel({ route, navKey }) {
104
110
  const tab = route.tab;
105
111
  useEffect(() => { setTitle(`${t("fleet.title")} · ${t(`fleet.${tab}`)}`); }, [tab, navKey]);
106
112
  const Body = { tasks: Tasks, schedules: Schedules, teams: Teams, org: Org, cache: Cache, config: Config }[tab] || Tasks;
113
+ // #1685: host memory pressure slowing or holding new agents — shown here, where macOS's routine "elevated" is not a chat notice.
114
+ const { hostMemory } = useStore(appStore);
115
+ 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
116
  return html`<div class="panel p-fleet">
108
- <${PanelHeader} title=${t("fleet.title")}><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
117
+ <${PanelHeader} title=${t("fleet.title")} sub=${sub}><${HeaderTools} navKey=${navKey} /></${PanelHeader}>
109
118
  <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
119
  aria-current=${k === tab ? "page" : undefined}><${Icon} name=${ICONS[k]} size=${16} /><span>${t(`fleet.${k}`)}</span></a>`)}</nav>
111
120
  <div class="panel-body"><div class=${`col${tab === "org" || tab === "cache" ? " col-wide" : ""}`}><${Body} lease=${lease} /></div></div>
@@ -167,9 +176,13 @@ function Schedules({ lease }) {
167
176
  ${creating ? html`<${CreateSchedule} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
168
177
  }
169
178
 
179
+ /** #1689: the public link changes no team (the server refuses it): it lists them only. */
180
+ const publicLink = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
181
+
170
182
  function Teams({ lease }) {
171
183
  const [version, setVersion] = useState(0);
172
184
  const [creating, setCreating] = useState(false);
185
+ const [editing, setEditing] = useState(null); // #1689: { name, members, description } of the team being edited
173
186
  const state = useLoad(lease, "/ui/teams", version);
174
187
  async function del(name) {
175
188
  if (!(await confirmDialog({ message: t("fleet.deleteTeam", name), confirmLabel: t("fleet.delete"), danger: true }))) return;
@@ -179,14 +192,17 @@ function Teams({ lease }) {
179
192
  }
180
193
  return html`<${Loaded} state=${state}>${(d) => {
181
194
  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) }} />
195
+ const writable = !publicLink();
196
+ 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
197
  ${entries.length ? html`<ul class="rows">${entries.map(([name, team]) => html`<li key=${name} class="row-item">
184
198
  <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>`
199
+ ${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>
200
+ <button type="button" class="btn btn-ghost btn-sm danger" onClick=${() => del(name)}>${t("fleet.delete")}</button>` : null}</li>`)}</ul>`
186
201
  : html`<${Empty} icon="team" title=${t("fleet.noTeams")} hint=${t("fleet.noTeamsHint")} />`}
187
202
  `;
188
203
  }}</${Loaded}>
189
- ${creating ? html`<${CreateTeam} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
204
+ ${creating ? html`<${CreateTeam} onClose=${() => setCreating(false)} onDone=${() => setVersion(v => v + 1)} />` : null}
205
+ ${editing ? html`<${CreateTeam} team=${editing} onClose=${() => setEditing(null)} onDone=${() => setVersion(v => v + 1)} />` : null}`;
190
206
  }
191
207
 
192
208
  /** #1389: the org chart — its structure read once per opening, its live state from the app store (fleet-org.js). */
@@ -286,7 +302,7 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
286
302
  // the dialog goes at once and the shell follows the request. From then on the dialog is done: the decision is
287
303
  // said in a toast, and nothing here closes, refreshes or acts again.
288
304
  try {
289
- const res = await confirmedWrite(submit.path, { method: "POST", body, label: title,
305
+ const res = await confirmedWrite(submit.path, { method: submit.method || "POST", body, label: title,
290
306
  onPending: () => { handedOver = true; toast(t("app.pendingSent")); if (lease.current()) { setBusy(false); onClose(); } } });
291
307
  r = res.ok ? res.body || {} : { error: (res.body && res.body.error) || `HTTP ${res.status}` };
292
308
  } catch (err) { r = { error: err.message }; }
@@ -299,7 +315,7 @@ export function FormDialog({ title, onClose, submit, children, ready = true }) {
299
315
  }
300
316
  return html`<${Dialog} title=${title} onClose=${onClose} busy=${busy}
301
317
  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>`}>
318
+ <button type="button" class="btn btn-primary" onClick=${go} disabled=${busy || !ready}>${busy ? t("fleet.creating") : submit.label || t("fleet.create")}</button>`}>
303
319
  <form class="form" onSubmit=${(e) => { e.preventDefault(); go(); }}><fieldset class="form" disabled=${busy}>${children}</fieldset></form></${Dialog}>`;
304
320
  }
305
321
  const field = (label, input) => html`<label class="field"><span>${label}</span>${input}</label>`;
@@ -342,21 +358,34 @@ function CreateSchedule({ onClose, onDone }) {
342
358
  </${FormDialog}>`;
343
359
  }
344
360
 
345
- function CreateTeam({ onClose, onDone }) {
361
+ /**
362
+ * New team, or (#1689) `team` given: edit that one — the same form, filled in from it: its members ticked (one no longer
363
+ * in the fleet listed too, so it can be taken out), its description; the name is not editable (a team is not renamed).
364
+ * Saved with PATCH /ui/teams/<name> (the update_team path on the host).
365
+ */
366
+ function CreateTeam({ onClose, onDone, team = null }) {
346
367
  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>
368
+ const [f, setF] = useState(team ? { name: team.name, desc: team.description || "", members: [...team.members] } : { name: "", desc: "", members: [] });
369
+ // From the state as it is, not this render's copy: two ticks before the next render must both count.
370
+ const toggle = (n) => setF((cur) => ({ ...cur, members: cur.members.includes(n) ? cur.members.filter(x => x !== n) : [...cur.members, n] }));
371
+ const names = [...new Set([...instances.map(i => i.name), ...(team ? team.members : [])])];
372
+ const submit = team
373
+ ? { path: `/ui/teams/${encodeURIComponent(team.name)}`, method: "PATCH", label: t("fleet.save"), done: t("fleet.teamUpdated"), after: onDone, collect: () => {
374
+ if (!f.members.length) { toast(t("fleet.teamFieldsRequired"), false); return null; }
375
+ return { members: f.members, description: f.desc.trim() };
376
+ } }
377
+ : { path: "/ui/teams", done: t("fleet.teamCreated"), after: onDone, collect: () => {
378
+ if (!f.name.trim() || !f.members.length) { toast(t("fleet.teamFieldsRequired"), false); return null; }
379
+ const body = { name: f.name.trim(), members: f.members };
380
+ if (f.desc.trim()) body.description = f.desc.trim();
381
+ return body;
382
+ } };
383
+ return html`<${FormDialog} title=${team ? t("fleet.editTeam") : t("fleet.newTeam")} onClose=${onClose} submit=${submit}>
384
+ ${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 })); }} />`)}
385
+ ${team ? html`<p class="note">${t("fleet.teamNoRename")}</p>` : null}
386
+ ${field(t("fleet.taskDesc"), html`<input value=${f.desc} onInput=${(e) => { const v = e.target.value; setF((cur) => ({ ...cur, desc: v })); }} />`)}
387
+ <fieldset class="field"><legend>${t("fleet.members")} *</legend><div class="checks">${names.map(n => html`<label key=${n} class="check">
388
+ <input type="checkbox" checked=${f.members.includes(n)} onChange=${() => toggle(n)} />${n}</label>`)}</div></fieldset>
360
389
  </${FormDialog}>`;
361
390
  }
362
391