@songsid/agend 2.3.2-beta.3 → 2.3.2-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 (108) hide show
  1. package/dist/agent-cli.js +21 -2
  2. package/dist/agent-cli.js.map +1 -1
  3. package/dist/agent-endpoint.js +5 -0
  4. package/dist/agent-endpoint.js.map +1 -1
  5. package/dist/auth-api.js +2 -0
  6. package/dist/auth-api.js.map +1 -1
  7. package/dist/backend/kiro.d.ts +21 -1
  8. package/dist/backend/kiro.js +89 -1
  9. package/dist/backend/kiro.js.map +1 -1
  10. package/dist/channel/adapters/discord.js +7 -4
  11. package/dist/channel/adapters/discord.js.map +1 -1
  12. package/dist/channel/mcp-tools.js +4 -0
  13. package/dist/channel/mcp-tools.js.map +1 -1
  14. package/dist/classic-channel-manager.d.ts +5 -0
  15. package/dist/classic-channel-manager.js +14 -1
  16. package/dist/classic-channel-manager.js.map +1 -1
  17. package/dist/config-validator.js +22 -5
  18. package/dist/config-validator.js.map +1 -1
  19. package/dist/config.js +6 -1
  20. package/dist/config.js.map +1 -1
  21. package/dist/daemon.js +16 -4
  22. package/dist/daemon.js.map +1 -1
  23. package/dist/dashboard-permissions.d.ts +20 -6
  24. package/dist/dashboard-permissions.js +58 -9
  25. package/dist/dashboard-permissions.js.map +1 -1
  26. package/dist/fleet-manager.d.ts +16 -1
  27. package/dist/fleet-manager.js +183 -21
  28. package/dist/fleet-manager.js.map +1 -1
  29. package/dist/general-knowledge/skills/web-appearance/SKILL.canvas-worker-off.md +24 -1
  30. package/dist/general-knowledge/skills/web-appearance/SKILL.md +24 -1
  31. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +6 -2
  32. package/dist/instance-config-impact.js +10 -5
  33. package/dist/instance-config-impact.js.map +1 -1
  34. package/dist/instance-lifecycle.d.ts +8 -0
  35. package/dist/instance-lifecycle.js +5 -3
  36. package/dist/instance-lifecycle.js.map +1 -1
  37. package/dist/locale.js +2 -6
  38. package/dist/locale.js.map +1 -1
  39. package/dist/notice-delivery.d.ts +15 -0
  40. package/dist/notice-delivery.js +37 -0
  41. package/dist/notice-delivery.js.map +1 -0
  42. package/dist/outbound-schemas.d.ts +20 -0
  43. package/dist/outbound-schemas.js +17 -0
  44. package/dist/outbound-schemas.js.map +1 -1
  45. package/dist/public-web-gateway.js +1 -1
  46. package/dist/public-web-gateway.js.map +1 -1
  47. package/dist/quickstart.js +2 -0
  48. package/dist/quickstart.js.map +1 -1
  49. package/dist/reply-triggers.d.ts +2 -0
  50. package/dist/reply-triggers.js +3 -0
  51. package/dist/reply-triggers.js.map +1 -0
  52. package/dist/settings-api.js +27 -4
  53. package/dist/settings-api.js.map +1 -1
  54. package/dist/settings-change.js +17 -14
  55. package/dist/settings-change.js.map +1 -1
  56. package/dist/settings-effect.js +7 -1
  57. package/dist/settings-effect.js.map +1 -1
  58. package/dist/settings-instance-patch.js +4 -1
  59. package/dist/settings-instance-patch.js.map +1 -1
  60. package/dist/setup-host.js +2 -1
  61. package/dist/setup-host.js.map +1 -1
  62. package/dist/setup-wizard.js +2 -0
  63. package/dist/setup-wizard.js.map +1 -1
  64. package/dist/tool-permissions.js +10 -0
  65. package/dist/tool-permissions.js.map +1 -1
  66. package/dist/topic-commands.js +2 -1
  67. package/dist/topic-commands.js.map +1 -1
  68. package/dist/turn-reply-guard.d.ts +1 -1
  69. package/dist/turn-reply-guard.js +1 -1
  70. package/dist/turn-reply-guard.js.map +1 -1
  71. package/dist/types.d.ts +16 -6
  72. package/dist/ui/fleet-office-editor.js +10 -6
  73. package/dist/ui/fleet-office-layout.js +34 -28
  74. package/dist/ui/fleet-office-model.js +6 -3
  75. package/dist/ui/fleet-office-overview.js +2 -2
  76. package/dist/ui/fleet-office-pack.d.ts +3 -1
  77. package/dist/ui/fleet-office-pack.js +58 -7
  78. package/dist/ui/fleet-office-skin.js +13 -4
  79. package/dist/ui/fleet-office-store.js +27 -1
  80. package/dist/ui/fleet-office-view.js +5 -4
  81. package/dist/ui/fleet-office-zones.js +155 -6
  82. package/dist/ui/fleet-office.js +169 -41
  83. package/dist/ui/panel-settings.js +32 -10
  84. package/dist/ui/settings-dialogs.js +8 -1
  85. package/dist/ui/settings-model.js +14 -6
  86. package/dist/ui/settings-registry.js +4 -0
  87. package/dist/ui/settings-strings.js +14 -0
  88. package/dist/ui/shared/app-i18n.js +2 -0
  89. package/dist/ui/shared/app-shell.js +14 -6
  90. package/dist/ui/shared/app-stream.js +2 -1
  91. package/dist/ui/shared/app.css +17 -2
  92. package/dist/ui/shared/side-panel.js +95 -0
  93. package/dist/web-api.d.ts +3 -0
  94. package/dist/web-api.js +11 -0
  95. package/dist/web-api.js.map +1 -1
  96. package/dist/web-auth.js +1 -1
  97. package/dist/web-auth.js.map +1 -1
  98. package/dist/web-office-acts.d.ts +173 -0
  99. package/dist/web-office-acts.js +285 -0
  100. package/dist/web-office-acts.js.map +1 -0
  101. package/dist/web-office-skins.d.ts +1 -1
  102. package/dist/web-office-skins.js +1 -1
  103. package/dist/web-office-skins.js.map +1 -1
  104. package/dist/web-settings-patch.d.ts +2 -1
  105. package/dist/web-settings-patch.js +4 -1
  106. package/dist/web-settings-patch.js.map +1 -1
  107. package/package.json +1 -1
  108. package/templates/windows-shortcuts.ps1 +11 -2
@@ -952,8 +952,13 @@ function Classic({ ctx, search, openDialog }) {
952
952
  })}</div>`;
953
953
  }
954
954
 
955
+ const configuredNoticeMode = (value, route) => value === false ? false : value === "dm" || route === "dm" ? "dm" : true;
956
+ const noticeMode = (v) => v === "dm" ? "dm" : v === "false" || v === false ? false : true;
957
+ const noticeChoices = () => [{ value: "true", label: tn("noticeTopic") }, { value: "dm", label: tn("noticeDm") }, { value: "false", label: tn("noticeOff") }];
958
+ const noticeLabel = (v) => tn(v === "dm" ? "noticeDm" : v === false ? "noticeOff" : "noticeTopic");
959
+
955
960
  /** General's form, from the saved defaults (`d`) and ClassicBot's (`c`). */
956
- function generalForm(d, c, fleet = {}) {
961
+ function generalForm(d, c, fleet = {}, schema = {}) {
957
962
  const hang = d.hang_detector || {}, startup = d.startup || {};
958
963
  return {
959
964
  backend: d.backend || "claude-code", model: d.model || "", locale: d.locale || "", auto: String(d.auto_pause_after ?? 0),
@@ -966,6 +971,9 @@ function generalForm(d, c, fleet = {}) {
966
971
  // #2347 §10 review: unset startup concurrency is automatic (deriveSpawnConcurrency: 2–10 by free memory and cores),
967
972
  // not 10 — an empty field, and a save that leaves it unset (the defaults merge replaces `startup` whole).
968
973
  concurrency: startup.concurrency == null ? "" : String(startup.concurrency), stagger: String(startup.stagger_delay_ms ?? 500),
974
+ tips: String(configuredNoticeMode(d.tips, fleet.web?.notice_delivery?.tips)),
975
+ replyTriggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default),
976
+ classicReplyTriggers: Boolean(c.reply_triggers ?? d.reply_triggers ?? schema.reply_triggers_default), classicReplyTriggersInherit: !hasOwn(c, "reply_triggers"),
969
977
  classicBackend: c.backend || d.backend || "claude-code",
970
978
  classicToolProgress: c.tool_progress ?? d.tool_progress ?? "off", classicToolProgressInherit: !hasOwn(c, "tool_progress"),
971
979
  classicReply: c.reply_completion_guard ?? d.reply_completion_guard ?? true, classicReplyInherit: !hasOwn(c, "reply_completion_guard"),
@@ -985,7 +993,7 @@ function General({ ctx, part = "all" }) {
985
993
  const hang = d.hang_detector || {}, startup = d.startup || {};
986
994
  const primary = channelsOf(ctx.fleet)[0];
987
995
  const original = useMemo(() => ({ c: structuredClone(c) }), []);
988
- const base = useMemo(() => generalForm(d, c, ctx.fleet), []);
996
+ const base = useMemo(() => generalForm(d, c, ctx.fleet, schema), []);
989
997
  const [f, setF] = useDraft(ctx, "general", () => base);
990
998
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
991
999
  /** The class of a field's row: `field`, and `s-dirty` while one of its keys differs from what is saved. */
@@ -1011,27 +1019,31 @@ function General({ ctx, part = "all" }) {
1011
1019
  const defaultsPatch = changedFields({
1012
1020
  backend: f.backend, model: f.model || undefined, locale: f.locale || null, auto_pause_after: num(f.auto),
1013
1021
  agent_mode: f.agent_mode, tool_set: f.tool_set || null, tool_progress: f.tool_progress, cross_instance_visibility: f.visibility,
1014
- reply_completion_guard: f.reply, log_level: f.log_level,
1015
- // The dashboard features every agent may use unless its own setting says otherwise. All three go, because the write
1022
+ tips: noticeMode(f.tips), reply_triggers: f.replyTriggers, reply_completion_guard: f.reply, log_level: f.log_level,
1023
+ // The dashboard features every agent may use unless its own setting says otherwise. All of them go, because the write
1016
1024
  // to the fleet's defaults merges top-level keys only (`dashboard` is replaced whole): the untouched ones as saved.
1017
- dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, f[`dash_${k}`]])),
1025
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, f[`dash_${k}`]])),
1026
+ office_access: f.dash_office,
1018
1027
  hang_detector: { enabled: f.hangOn, timeout_minutes: num(f.hang) },
1019
1028
  startup: { ...(f.concurrency.trim() === "" ? {} : { concurrency: num(f.concurrency) }), stagger_delay_ms: num(f.stagger) },
1020
1029
  }, {
1021
1030
  backend: d.backend || "claude-code", model: d.model || undefined, locale: d.locale || null, auto_pause_after: d.auto_pause_after ?? 0,
1022
1031
  agent_mode: d.agent_mode || "mcp", tool_set: TOOL_SETS.includes(d.tool_set) ? d.tool_set : null, tool_progress: d.tool_progress ?? "off",
1023
- cross_instance_visibility: visibilityDefault(d), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1024
- dashboard: Object.fromEntries(DASHBOARD_FEATURES.map((k) => [k, dashboardDefault(d, k)])),
1032
+ tips: configuredNoticeMode(d.tips, ctx.fleet.web?.notice_delivery?.tips), cross_instance_visibility: visibilityDefault(d), reply_triggers: Boolean(d.reply_triggers ?? schema.reply_triggers_default), reply_completion_guard: d.reply_completion_guard ?? true, log_level: d.log_level || "info",
1033
+ dashboard: Object.fromEntries(DASHBOARD_FEATURES.filter((k) => k !== "office").map((k) => [k, dashboardDefault(d, k)])),
1034
+ office_access: dashboardDefault(d, "office"),
1025
1035
  hang_detector: { enabled: hang.enabled !== false, timeout_minutes: hang.timeout_minutes ?? HANG_TIMEOUT_DEFAULT_MINUTES },
1026
1036
  startup: { ...(startup.concurrency == null ? {} : { concurrency: startup.concurrency }), stagger_delay_ms: startup.stagger_delay_ms ?? 500 },
1027
1037
  });
1028
1038
  const oc = original.c;
1029
1039
  const classicPatch = changedFields({
1040
+ reply_triggers: f.classicReplyTriggersInherit ? null : f.classicReplyTriggers,
1030
1041
  backend: f.classicBackend,
1031
1042
  tool_progress: f.classicToolProgressInherit ? null : f.classicToolProgress,
1032
1043
  reply_completion_guard: f.classicReplyInherit ? null : f.classicReply,
1033
1044
  admin_users: f.admins, allowed_guilds: f.guilds,
1034
1045
  }, {
1046
+ reply_triggers: hasOwn(oc, "reply_triggers") ? oc.reply_triggers : null,
1035
1047
  backend: oc.backend || d.backend || "claude-code",
1036
1048
  tool_progress: hasOwn(oc, "tool_progress") ? oc.tool_progress : null,
1037
1049
  reply_completion_guard: hasOwn(oc, "reply_completion_guard") ? oc.reply_completion_guard : null,
@@ -1054,7 +1066,7 @@ function General({ ctx, part = "all" }) {
1054
1066
  // ClassicBot's inherited ones say what they inherit from Agent defaults.
1055
1067
  const unset = {
1056
1068
  locale: !d.locale, backend: !d.backend, model: !d.model, auto: !hasOwn(d, "auto_pause_after"), tool_progress: !hasOwn(d, "tool_progress"),
1057
- visibility: !VISIBILITY_MODES.includes(d.cross_instance_visibility), reply: !hasOwn(d, "reply_completion_guard"), tool_set: !d.tool_set,
1069
+ tips: !hasOwn(d, "tips"), replyTriggers: !hasOwn(d, "reply_triggers"), visibility: !VISIBILITY_MODES.includes(d.cross_instance_visibility), reply: !hasOwn(d, "reply_completion_guard"), tool_set: !d.tool_set,
1058
1070
  log_level: !d.log_level, agent_mode: !d.agent_mode, hangOn: !hasOwn(hang, "enabled"), hang: !hasOwn(hang, "timeout_minutes"),
1059
1071
  concurrency: !hasOwn(startup, "concurrency"), stagger: !hasOwn(startup, "stagger_delay_ms"),
1060
1072
  };
@@ -1077,6 +1089,8 @@ function General({ ctx, part = "all" }) {
1077
1089
  <div class=${fc("field", "tool_progress")}>${L(tn("toolProgress"), "defaults.tool_progress", "g-tp", "tool_progress")}<${Select} id="g-tp" value=${f.tool_progress} onChange=${set("tool_progress")} options=${TOOL_PROGRESS} /></div>
1078
1090
  <div class=${fc("field", "visibility")}>${L(tn("crossInstanceVisibility"), "defaults.cross_instance_visibility", "g-vis", "visibility")}<${Select} id="g-vis" value=${f.visibility} onChange=${set("visibility")} options=${VISIBILITY_MODES} />
1079
1091
  <p class="note">${tn("crossInstanceVisibilityHint")}</p></div>
1092
+ <div class=${fc("field", "tips")}>${L(tn("dailyTips"), "defaults.tips", "g-tips", "tips")}<${Select} id="g-tips" value=${f.tips} onChange=${set("tips")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.tips))} /><p class="note">${tn("dailyTipsHint")}</p></div>
1093
+ <div class=${fc("field", "replyTriggers")}><label class="check"><input id="g-reply-triggers" type="checkbox" checked=${f.replyTriggers} onChange=${(e) => set("replyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span>${dm("replyTriggers")}<${Impact} schema=${schema} field="defaults.reply_triggers" /></label><p class="note">${tn("replyTriggersHint")}</p></div>
1080
1094
  <div class=${fc("field", "reply")}><label class="check"><input id="g-reply" type="checkbox" checked=${f.reply} onChange=${(e) => set("reply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span>${dm("reply")}<${Impact} schema=${schema} field="defaults.reply_completion_guard" /></label>
1081
1095
  <p class="note">${fleetReply}</p></div>
1082
1096
  </div>
@@ -1093,6 +1107,9 @@ function General({ ctx, part = "all" }) {
1093
1107
  <div class=${fc("field", "classicToolProgress", "classicToolProgressInherit")}>${L(tn("toolProgress"), "classic_defaults.tool_progress", "g-ctp")}
1094
1108
  <${Override} inherit=${f.classicToolProgressInherit} onInherit=${set("classicToolProgressInherit")} inherited=${fromDefaults("tool_progress", d.tool_progress ?? "off", "#g-tp")}>
1095
1109
  <${Select} id="g-ctp" disabled=${f.classicToolProgressInherit} value=${f.classicToolProgressInherit ? (d.tool_progress ?? "off") : f.classicToolProgress} onChange=${set("classicToolProgress")} options=${TOOL_PROGRESS} /></${Override}></div>
1110
+ <div class=${fc("field", "classicReplyTriggers", "classicReplyTriggersInherit")}>${L(tn("replyTriggers"), "classic_defaults.reply_triggers")}
1111
+ <${Override} inherit=${f.classicReplyTriggersInherit} onInherit=${set("classicReplyTriggersInherit")} inherited=${fromDefaults("reply_triggers", Boolean(d.reply_triggers ?? schema.reply_triggers_default), "#g-reply-triggers")}>
1112
+ <label class="check"><input id="g-creply-triggers" type="checkbox" disabled=${f.classicReplyTriggersInherit} checked=${f.classicReplyTriggersInherit ? Boolean(d.reply_triggers ?? schema.reply_triggers_default) : f.classicReplyTriggers} onChange=${(e) => set("classicReplyTriggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span></label></${Override}><p class="note">${tn("replyTriggersHint")}</p></div>
1096
1113
  <div class=${fc("field", "classicReply", "classicReplyInherit")}>${L(tn("replyCompletionGuard"), "classic_defaults.reply_completion_guard")}
1097
1114
  <${Override} inherit=${f.classicReplyInherit} onInherit=${set("classicReplyInherit")} inherited=${fromDefaults("reply_completion_guard", d.reply_completion_guard ?? true, "#g-reply")}>
1098
1115
  <label class="check"><input id="g-creply" type="checkbox" disabled=${f.classicReplyInherit} checked=${f.classicReplyInherit ? (d.reply_completion_guard ?? true) : f.classicReply} onChange=${(e) => set("classicReply")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
@@ -1140,7 +1157,7 @@ function General({ ctx, part = "all" }) {
1140
1157
  * the temporary public link none of it can be changed — the form says so and stays disabled (the server refuses too).
1141
1158
  */
1142
1159
  export const WEB_READ_ONLY = [
1143
- "web.allowed_hosts", "web.view_access", "web.notify_login", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
1160
+ "web.allowed_hosts", "web.view_access", "web.preview", "web.preview_port", "web.preview_origin", "web.preview_on_public_link",
1144
1161
  "web.usage_panel", "web.search", "web.provider_secrets", "web.upload_max_file_mb", "web.upload_max_total_mb", "web_terminal.bind", "web_terminal.tunnel",
1145
1162
  ];
1146
1163
  /**
@@ -1173,6 +1190,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1173
1190
  // serves their reads there).
1174
1191
  const overPublicLink = globalThis.document?.body?.dataset?.publicLink === "1";
1175
1192
  const original = useMemo(() => ({
1193
+ notify: configuredNoticeMode(web.notify_login, web.notice_delivery?.login),
1176
1194
  echo: web.echo_to_channel ?? true,
1177
1195
  log: web.log_tab === true, // #1218: off by default
1178
1196
  usagePub: web.usage_report_on_public_link === true, // #2122: off by default
@@ -1182,7 +1200,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1182
1200
  }), []);
1183
1201
  const text = (v) => (v === null || v === undefined ? "" : String(v));
1184
1202
  /** #2347: a field's row class: `s-dirty` while it differs from what is saved (the draft may come from before). */
1185
- const savedForm = useMemo(() => ({ echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
1203
+ const savedForm = useMemo(() => ({ notify: String(original.notify), echo: original.echo, log: original.log, usagePub: original.usagePub, pub: original.pub.allow_public,
1186
1204
  ttl: String(original.pub.ttl_minutes), protocol: original.pub.protocol, hostname: original.pub.hostname, originPort: String(original.pub.origin_port),
1187
1205
  abs: text(original.session.absolute_hours), idle: text(original.session.idle_hours), termOn: original.terminal.enabled !== false,
1188
1206
  termTtl: text(original.terminal.ttl_minutes), autoOpen: original.pub.auto_open }), []);
@@ -1194,6 +1212,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1194
1212
  const pub = pending.public_link || {}, session = pending.session?.local || {}, terminal = pending.web_terminal || {};
1195
1213
  const pick = (obj, key, fallback) => Object.prototype.hasOwnProperty.call(obj, key) ? obj[key] : fallback;
1196
1214
  return {
1215
+ notify: String(pick(pending, "notify_login", original.notify)),
1197
1216
  echo: pick(pending, "echo_to_channel", original.echo), log: pick(pending, "log_tab", original.log), usagePub: pick(pending, "usage_report_on_public_link", original.usagePub),
1198
1217
  pub: pick(pub, "allow_public", original.pub.allow_public), ttl: String(pick(pub, "ttl_minutes", original.pub.ttl_minutes)), protocol: pick(pub, "protocol", original.pub.protocol),
1199
1218
  hostname: pick(pub, "hostname", original.pub.hostname), originPort: String(pick(pub, "origin_port", original.pub.origin_port)), autoOpen: pick(pub, "auto_open", original.pub.auto_open),
@@ -1242,6 +1261,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1242
1261
  const terminalPatch = changedFields({ enabled: f.termOn === (original.terminal.enabled !== false) ? original.terminal.enabled : f.termOn ? null : false,
1243
1262
  ttl_minutes: value(f.termTtl) }, original.terminal);
1244
1263
  const body = {
1264
+ ...(noticeMode(f.notify) !== original.notify ? { notify_login: noticeMode(f.notify) } : {}),
1245
1265
  ...(f.echo !== original.echo ? { echo_to_channel: f.echo } : {}),
1246
1266
  ...(f.log !== original.log ? { log_tab: f.log } : {}),
1247
1267
  ...(f.usagePub !== original.usagePub ? { usage_report_on_public_link: f.usagePub } : {}),
@@ -1250,6 +1270,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1250
1270
  ...(Object.keys(terminalPatch).length ? { web_terminal: terminalPatch } : {}),
1251
1271
  };
1252
1272
  const fields = [
1273
+ ...(body.notify_login !== undefined ? ["web.notify_login"] : []),
1253
1274
  ...(body.echo_to_channel !== undefined ? ["web.echo_to_channel"] : []),
1254
1275
  ...(body.log_tab !== undefined ? ["web.log_tab"] : []),
1255
1276
  ...(body.usage_report_on_public_link !== undefined ? ["web.usage_report_on_public_link"] : []),
@@ -1297,6 +1318,7 @@ function WebSection({ ctx, staged, part = "all" }) {
1297
1318
  ${overPublicLink && part !== "readonly" ? html`<p class="note" role="status" id="webPublicRefused">${tn("webPublicRefused")}</p>` : null}
1298
1319
  ${has("access") ? lock(html`<section class="card"><h3>${tn("webSessionTitle")}</h3>
1299
1320
  <p class="note">${tn("webSessionHint")}</p>
1321
+ <div class=${fc("field", "notify")}><label for="webNotifyLogin">${tn("webNotifyLogin")}<${Impact} schema=${schema} field="web.notify_login" /></label><${Select} id="webNotifyLogin" value=${f.notify} onChange=${set("notify")} options=${noticeChoices()} /><${InEffect} text=${noticeLabel(noticeMode(f.notify))} />${web.notify_login === undefined ? html`<${DefaultMark} />` : null}<p class="note">${tn("webNotifyLoginHint")}</p></div>
1300
1322
  <div class="grid2">
1301
1323
  <div class=${fc("field", "abs")}><label for="webSessionAbsolute">${tn("sessionAbsolute")}</label><input id="webSessionAbsolute" type="number" min="1" max="720" step="any"
1302
1324
  class=${errors.abs ? "invalid" : ""} value=${f.abs} onInput=${(e) => set("abs")(e.target.value)} /><${Feedback} error=${errors.abs} />
@@ -642,6 +642,7 @@ export function ClassicDialog({ room, ctx, onClose }) {
642
642
  const inherited = {
643
643
  backend: cd.backend || fd.backend || "claude-code", model: cd.model || fd.model || "",
644
644
  auto_pause_after: cd.auto_pause_after ?? fd.auto_pause_after ?? 0, tool_progress: cd.tool_progress ?? fd.tool_progress ?? "off",
645
+ reply_triggers: Boolean(cd.reply_triggers ?? fd.reply_triggers ?? schema.reply_triggers_default),
645
646
  reply_completion_guard: cd.reply_completion_guard ?? fd.reply_completion_guard ?? true,
646
647
  };
647
648
  const c = room;
@@ -649,8 +650,9 @@ export function ClassicDialog({ room, ctx, onClose }) {
649
650
  backend: c.backend || inherited.backend, model: c.model || "",
650
651
  auto_pause_after: String(c.auto_pause_after ?? inherited.auto_pause_after), tool_progress: c.tool_progress ?? inherited.tool_progress,
651
652
  reply_completion_guard: c.reply_completion_guard ?? inherited.reply_completion_guard,
653
+ reply_triggers: c.reply_triggers ?? inherited.reply_triggers,
652
654
  collab: !!c.collab, web_echo: classicWebEchoEffective(c, fleet), context_lines: String(c.context_lines ?? cd.context_lines ?? 5),
653
- inherit: { auto_pause_after: !hasOwn(c, "auto_pause_after"), tool_progress: !hasOwn(c, "tool_progress"), reply_completion_guard: !hasOwn(c, "reply_completion_guard") },
655
+ inherit: { reply_triggers: !hasOwn(c, "reply_triggers"), auto_pause_after: !hasOwn(c, "auto_pause_after"), tool_progress: !hasOwn(c, "tool_progress"), reply_completion_guard: !hasOwn(c, "reply_completion_guard") },
654
656
  }));
655
657
  const set = (k) => (v) => setF((x) => ({ ...x, [k]: v }));
656
658
  const setInherit = (k) => (on) => setF((x) => ({ ...x, inherit: { ...x.inherit, [k]: on } }));
@@ -664,12 +666,14 @@ export function ClassicDialog({ room, ctx, onClose }) {
664
666
  auto_pause_after: f.inherit.auto_pause_after ? null : Number(f.auto_pause_after),
665
667
  tool_progress: f.inherit.tool_progress ? null : f.tool_progress,
666
668
  reply_completion_guard: f.inherit.reply_completion_guard ? null : f.reply_completion_guard,
669
+ reply_triggers: f.inherit.reply_triggers ? null : f.reply_triggers,
667
670
  collab: f.collab, context_lines: Number(f.context_lines), web_echo: f.web_echo,
668
671
  }, {
669
672
  backend: c.backend || inherited.backend, model: (c.model && c.model.trim()) || null,
670
673
  auto_pause_after: hasOwn(c, "auto_pause_after") ? c.auto_pause_after : null,
671
674
  tool_progress: hasOwn(c, "tool_progress") ? c.tool_progress : null,
672
675
  reply_completion_guard: hasOwn(c, "reply_completion_guard") ? c.reply_completion_guard : null,
676
+ reply_triggers: hasOwn(c, "reply_triggers") ? c.reply_triggers : null,
673
677
  collab: !!c.collab, context_lines: c.context_lines ?? cd.context_lines ?? 5, web_echo: classicWebEchoEffective(c, fleet),
674
678
  });
675
679
  const stage = async () => {
@@ -707,6 +711,9 @@ export function ClassicDialog({ room, ctx, onClose }) {
707
711
  <label class="check"><input type="checkbox" disabled=${f.inherit.reply_completion_guard} checked=${f.inherit.reply_completion_guard ? inherited.reply_completion_guard : f.reply_completion_guard}
708
712
  onChange=${(e) => set("reply_completion_guard")(e.target.checked)} /><span>${tn("replyCompletionGuard")}</span></label></${Override}>
709
713
  <p class="note">${replyHint(f.backend, classicMode, fd.kiro_ui)}</p></div>
714
+ <div class="field">${L(tn("replyTriggers"), "classic.reply_triggers")}
715
+ <${Override} inherit=${f.inherit.reply_triggers} onInherit=${setInherit("reply_triggers")} inherited=${roomInh("reply_triggers", "", "#g-creply-triggers", "#g-reply-triggers")}>
716
+ <label class="check"><input type="checkbox" disabled=${f.inherit.reply_triggers} checked=${f.inherit.reply_triggers ? inherited.reply_triggers : f.reply_triggers} onChange=${(e) => set("reply_triggers")(e.target.checked)} /><span>${tn("replyTriggers")}</span></label></${Override}><p class="note">${tn("replyTriggersHint")}</p></div>
710
717
  <label class="check"><input type="checkbox" checked=${f.collab} onChange=${(e) => set("collab")(e.target.checked)} /><span>${tn("collabMode")}</span><${Impact} schema=${schema} field="classic.collab" /></label>
711
718
  <label class="check"><input type="checkbox" checked=${f.web_echo} onChange=${(e) => set("web_echo")(e.target.checked)} /><span>${tn("webEcho")}</span><${Impact} schema=${schema} field="classic.web_echo" /></label>
712
719
  </div>
@@ -40,19 +40,25 @@ export function changedFields(next, baseline) {
40
40
 
41
41
  /**
42
42
  * The dashboard features an agent may use (dashboard-permissions.ts): each "inherit" (the fleet default: `defaults.dashboard`,
43
- * else allowed), "on" or "off". The patch names only the features that changed: null follows the default again.
43
+ * else allowed — off for OFF_UNLESS_ON, `office`), "on" or "off". The patch names only the features that changed: null
44
+ * follows the default again.
44
45
  */
45
- export const DASHBOARD_FEATURES = ["appearance", "task_board", "web_settings"];
46
+ export const DASHBOARD_FEATURES = ["appearance", "task_board", "web_settings", "office"];
47
+ /** #2578: the features a person switches on — unset is off (dashboard-permissions.ts OFF_UNLESS_ON). */
48
+ export const OFF_UNLESS_ON = ["office"];
46
49
  export const DASHBOARD_CHOICES = ["inherit", "on", "off"];
47
50
  const dashboardOf = (v) => (v && typeof v === "object" && !Array.isArray(v) ? v : {});
48
- /** What a feature is for an agent with no setting of its own: the default's, else allowed. */
49
- export const dashboardDefault = (defaults, feature) => dashboardOf(defaults && defaults.dashboard)[feature] !== false;
51
+ /** What a feature is for an agent with no setting of its own: the default's, else allowed (off for OFF_UNLESS_ON). */
52
+ export const dashboardDefault = (defaults, feature) => {
53
+ const v = feature === "office" && defaults && Object.hasOwn(defaults, "office_access") ? defaults.office_access : dashboardOf(defaults && defaults.dashboard)[feature];
54
+ return OFF_UNLESS_ON.includes(feature) ? v === true : v !== false;
55
+ };
50
56
  export function dashboardForm(inst) {
51
- const own = dashboardOf(inst.dashboard);
57
+ const own = { ...dashboardOf(inst.dashboard), ...(Object.hasOwn(inst, "office_access") ? { office: inst.office_access } : {}) };
52
58
  return Object.fromEntries(DASHBOARD_FEATURES.map((f) => [f, own[f] === true ? "on" : own[f] === false ? "off" : "inherit"]));
53
59
  }
54
60
  export function dashboardPatchValue(choices) {
55
- return Object.fromEntries(DASHBOARD_FEATURES.map((f) => [f, choices[f] === "on" ? true : choices[f] === "off" ? false : null]));
61
+ return Object.fromEntries(DASHBOARD_FEATURES.filter((f) => f !== "office").map((f) => [f, choices[f] === "on" ? true : choices[f] === "off" ? false : null]));
56
62
  }
57
63
 
58
64
  /** #1302: cross-instance notices. Unset, or a value the fleet does not know, means full. */
@@ -175,6 +181,7 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
175
181
  display_name: form.inherit.display_name ? null : form.display_name.trim(),
176
182
  status_emojis: form.status_emojis,
177
183
  dashboard: dashboardPatchValue(form.dashboard),
184
+ office_access: form.dashboard.office === "on" ? true : form.dashboard.office === "off" ? false : null,
178
185
  };
179
186
  return changedFields(next, {
180
187
  backend: inst.backend || defaults.backend || "claude-code",
@@ -198,6 +205,7 @@ export function agentPatch(form, inst, defaults, visibilityBaseline, statusEmoji
198
205
  display_name: hasOwn(inst, "display_name") ? inst.display_name : null,
199
206
  status_emojis: statusEmojisBaseline,
200
207
  dashboard: dashboardPatchValue(dashboardForm(inst)),
208
+ office_access: dashboardForm(inst).office === "on" ? true : dashboardForm(inst).office === "off" ? false : null,
201
209
  });
202
210
  }
203
211
 
@@ -33,6 +33,8 @@ const ROWS = [
33
33
  // Fleet · Agents
34
34
  ["agents.new", "agents", "#newAgentBtn", "settings.newAgent", null, ["new agent", "create", "add agent", "instance"], ["新增 agent", "建立", "新 agent"]],
35
35
  ["agents.provider-keys", "agents", ".s-provider-keys", "settings.providerKeys", "web.provider_secrets", ["api key", "provider", "openai", "anthropic", "token", "secret"], ["API 金鑰", "金鑰", "供應商", "token"]],
36
+ ["defaults.tips", "agent-defaults", "#g-tips", "settings.dailyTips", "defaults.tips", ["daily tips", "dm", "notices"], ["每日提示", "私訊"]],
37
+ ["web.notify-login", "access", "#webNotifyLogin", "settings.webNotifyLogin", "web.notify_login", ["sign in", "login", "dm", "notices"], ["登入通知", "私訊"]],
36
38
  // Fleet · Agent defaults
37
39
  ["defaults.locale", "agent-defaults", "#g-locale", "settings.language", "defaults.locale", ["language", "bot language", "reply language", "locale"], ["語言", "回覆語言", "bot 語言"]],
38
40
  ["defaults.backend", "agent-defaults", "#g-be", "settings.defaultBackend", "defaults.backend", ["backend", "cli", "claude", "codex", "kiro"], ["後端", "CLI"]],
@@ -40,6 +42,8 @@ const ROWS = [
40
42
  ["defaults.auto-pause", "agent-defaults", "#g-auto", "settings.autoPause", "defaults.auto_pause_after", ["auto pause", "idle", "sleep", "minutes"], ["自動暫停", "閒置", "休眠"]],
41
43
  ["defaults.tool-progress", "agent-defaults", "#g-tp", "settings.toolProgress", "defaults.tool_progress", ["tool progress", "verbose", "steps"], ["工具進度", "詳細"]],
42
44
  ["defaults.visibility", "agent-defaults", "#g-vis", "settings.crossInstanceVisibility", "defaults.cross_instance_visibility", ["visibility", "bot to bot", "cross instance", "messages"], ["可見性", "bot 之間", "跨 instance"]],
45
+ ["defaults.reply-triggers", "agent-defaults", "#g-reply-triggers", "settings.replyTriggers", "defaults.reply_triggers", ["classic native reply", "reply trigger"], ["原生回覆觸發"]],
46
+ ["chat.classic-reply-triggers", "chat", "#g-creply-triggers", "settings.replyTriggers", "classic_defaults.reply_triggers", ["classic native reply"], ["ClassicBot 原生回覆觸發"]],
43
47
  ["defaults.reply-guard", "agent-defaults", "#g-reply", "settings.replyCompletionGuard", "defaults.reply_completion_guard", ["delivered reply", "reply guard", "require reply"], ["送達回覆", "回覆保護"]],
44
48
  ["defaults.tool-set", "agent-defaults", "#g-ts", "settings.toolSet", "defaults.tool_set", ["tool set", "tools", "mcp"], ["工具組", "工具"]],
45
49
  ["defaults.dashboard", "agent-defaults", "#g-dash", "settings.dashboardAccess", "defaults.dashboard", ["dashboard access", "dashboard features", "permissions", "appearance tools", "task board", "web settings"], ["Dashboard 權限", "權限", "外觀", "任務看板", "網頁設定"]],
@@ -4,6 +4,10 @@ import { register } from "/assets/app-i18n.js";
4
4
 
5
5
  register("settings", {
6
6
  en: {
7
+ noticeTopic: "Topic", noticeDm: "Direct message (topic fallback)", noticeOff: "Off",
8
+ webNotifyLogin: "Web sign-in notices", dailyTips: "Daily tips",
9
+ webNotifyLoginHint: "DM the signing-in operator or public-link owner; local guest notices go to the owning connection’s first configured admin. Missing account or failed DM falls back to the topic. Off suppresses operator notices only; guest and public-link security notices remain in the topic. Existing default: Topic; new fleets: DM.",
10
+ dailyTipsHint: "DM the General connection’s first configured admin, with topic fallback. Default: Topic. /tips still posts where invoked.",
7
11
  title: "Settings",
8
12
  myAgents: "My Agents",
9
13
  bots: "Bots",
@@ -108,6 +112,7 @@ register("settings", {
108
112
  dashboard_appearance: "Its look (theme, pictures, widgets, effects, mod proposals)",
109
113
  dashboard_task_board: "The task board",
110
114
  dashboard_web_settings: "Your web appearance settings",
115
+ dashboard_office: "Office: walk, speak and act in the Office view",
111
116
  dashboardInherit: "Fleet default ({0})",
112
117
  dashboardOn: "allowed",
113
118
  dashboardOff: "off",
@@ -570,6 +575,8 @@ register("settings", {
570
575
  removeItem: "Remove {0}",
571
576
  zeroDisabled: "0 = off",
572
577
  optional: "optional",
578
+ replyTriggers: "Native replies trigger ClassicBot",
579
+ replyTriggersHint: "Off by default: only an exact @bot mention triggers a collab room. On also accepts one-hop replies to that bot. Forwarded content never counts; fleet-topic traffic is unchanged.",
573
580
  collabMode: "Collaboration mode",
574
581
  contextLines: "Context lines",
575
582
  refresh: "Refresh",
@@ -672,6 +679,10 @@ register("settings", {
672
679
  se_externalEmojiHint: "使用其他伺服器的 emoji,bot 在要加反應的頻道需有「使用外部表情符號」權限。",
673
680
  se_unavailable: "無法使用",
674
681
  se_hint: "留空即使用預設值。預覽由 AgEnD 依 bot 實際 react 的方式解析。進度前綴不是反應,而是進度訊息開頭的 emoji。",
682
+ noticeTopic: "頻道", noticeDm: "私訊(失敗時改送頻道)", noticeOff: "關閉",
683
+ webNotifyLogin: "網頁登入通知", dailyTips: "每日提示",
684
+ webNotifyLoginHint: "私訊登入的管理者或公開連結擁有者;本機訪客登入通知送給所屬連線的第一位設定管理員。帳號不明或私訊失敗時改送原頻道。關閉只停用管理者登入通知;訪客與公開連結安全通知仍送頻道。既有預設:頻道;新 fleet:私訊。",
685
+ dailyTipsHint: "私訊 General 所屬連線的第一位設定管理員,失敗時改送頻道。預設:頻道。/tips 仍送至呼叫的聊天室。",
675
686
  title: "設定",
676
687
  myAgents: "我的 Agent",
677
688
  bots: "機器人",
@@ -750,6 +761,7 @@ register("settings", {
750
761
  dashboard_appearance: "它的外觀(主題、圖片、小工具、特效、mod 提案)",
751
762
  dashboard_task_board: "任務看板",
752
763
  dashboard_web_settings: "你的網頁外觀設定",
764
+ dashboard_office: "辦公室:在辦公室畫面走動、說話、做動作",
753
765
  dashboardInherit: "跟 fleet 預設({0})",
754
766
  dashboardOn: "允許",
755
767
  dashboardOff: "關閉",
@@ -1210,6 +1222,8 @@ register("settings", {
1210
1222
  removeItem: "移除 {0}",
1211
1223
  zeroDisabled: "0 = 關閉",
1212
1224
  optional: "選填",
1225
+ replyTriggers: "原生回覆觸發 ClassicBot",
1226
+ replyTriggersHint: "預設關閉:協作房間只有精確 @bot 提及才觸發。開啟後也接受對該機器人的單層原生回覆;轉發內容不算。Fleet 話題的訊息接收方式不變。",
1213
1227
  collabMode: "協作模式",
1214
1228
  contextLines: "上下文行數",
1215
1229
  refresh: "重新整理",
@@ -103,6 +103,7 @@ register("app", {
103
103
  // #2293: global search — chats, cross-instance messages and tasks (not the sidebar's filter of agents).
104
104
  search: "Search", searchTitle: "Search chats, messages between agents and tasks ({0})",
105
105
  confirmTitle: "Please confirm", confirmOk: "Continue", cancel: "Cancel",
106
+ sidePanelLeave: "Leave {0} without saving? Your changes there will be lost.", sidePanelDiscard: "Discard changes",
106
107
  // The server's own wording (src/locale.ts needs.reason.*), so the web says what Discord says.
107
108
  needs_reason_hang: "Not responding", needs_reason_exited: "Exited", needs_reason_assist: "Waiting at its terminal", needs_reason_permission: "Permission needed", needs_reason_dangerous_command: "Dangerous command waiting", needs_reason_login: "Sign-in needed", needs_reason_dialog: "Waiting at a dialog", needs_reason_terminal_input: "May be waiting for input", needs_reason_auth_paused: "Paused: sign-in needed", needs_reason_crashed: "Crashed", needs_reason_delivery_uncertain: "Could not confirm delivery", needs_reason_delivery_failed: "Delivery failed", needs_reason_task_review: "Waiting for your confirmation", needs_reason_task_blocked: "Blocked: asks you",
108
109
  },
@@ -143,6 +144,7 @@ register("app", {
143
144
  needsNav: "等你處理", needsTab: "待處理", needsCount: "{0} 件待處理",
144
145
  search: "搜尋", searchTitle: "搜尋聊天、agent 之間的訊息與任務({0})",
145
146
  confirmTitle: "請確認", confirmOk: "繼續", cancel: "取消",
147
+ sidePanelLeave: "不儲存就離開「{0}」?你在那裡的修改會遺失。", sidePanelDiscard: "捨棄修改",
146
148
  needs_reason_hang: "沒有回應", needs_reason_exited: "已結束", needs_reason_assist: "在終端機上等待", needs_reason_permission: "需要權限", needs_reason_dangerous_command: "危險指令待確認", needs_reason_login: "需要登入", needs_reason_dialog: "停在對話框", needs_reason_terminal_input: "可能在等輸入", needs_reason_auth_paused: "已暫停:需要登入", needs_reason_crashed: "已崩潰", needs_reason_delivery_uncertain: "無法確認是否送達", needs_reason_delivery_failed: "傳送失敗", needs_reason_task_review: "等你確認", needs_reason_task_blocked: "卡住,在問你",
147
149
  },
148
150
  });
@@ -16,7 +16,8 @@ import { onPrefsChanged, savePref } from "./app-prefs.js";
16
16
  import { applyPageTheme, pageNow, resolvedMode, themesStore } from "./app-themes.js";
17
17
  import { ErrorState, Skeleton } from "./ui-states.js";
18
18
  import { InstanceNav, RosterNav, namesStore, setNamesPrimary } from "./instance-nav.js";
19
- import { UsageRail, usageRailStore } from "./header-tools.js";
19
+ import { usageRailStore } from "./header-tools.js";
20
+ import { RightRail, sidePanelStore } from "./side-panel.js";
20
21
  import { OwnerCssKeepBar } from "./owner-css.js";
21
22
  import { AgentWidget, fleetBinds, sidebarWidget, sidebarWidgetStore } from "./agent-widget.js";
22
23
 
@@ -89,14 +90,17 @@ export function openSearch(preset = {}) {
89
90
  }
90
91
  /**
91
92
  * Ctrl+K (⌘K) anywhere opens search; Ctrl+F (⌘F) in an agent's chat opens it narrowed to that agent — elsewhere the
92
- * browser's own find stays. Not while another dialog is open (search, open already, has its input focused).
93
+ * browser's own find stays. Not while another dialog is open (search, open already, has its input focused); a docked
94
+ * side panel (#2585) is not one.
93
95
  */
94
96
  export function handleSearchKey(e) {
95
97
  if (!searchOffered(true)) return;
96
98
  if (e.isComposing || e.altKey || e.shiftKey || !(isMacLike() ? e.metaKey : e.ctrlKey)) return;
97
99
  const key = (e.key || "").toLowerCase();
98
100
  if (key !== "k" && key !== "f") return;
99
- if (typeof document !== "undefined" && document.querySelector("dialog[open]")) return;
101
+ // #2585: a docked side panel is not modal — the page beside it is in use, so search is too (a result that leaves
102
+ // the page asks first when the panel holds changes: side-panel.js).
103
+ if (typeof document !== "undefined" && document.querySelector("dialog[open]:not(.side-panel)")) return;
100
104
  const route = navStore.get().route;
101
105
  if (key === "f" && !(route && route.panel === "chat" && route.instance)) return;
102
106
  e.preventDefault();
@@ -127,7 +131,7 @@ export const sidebarWidthStore = createStore({ width: null });
127
131
  function mainBudget() {
128
132
  const vw = viewportWidth();
129
133
  if (vw < 1200 || typeof document === "undefined") return vw;
130
- const rail = document.querySelector(".u-rail");
134
+ const rail = document.querySelector(".shell > .u-rail, .shell > .side-panel"); // #2585: a docked panel in its place
131
135
  if (!rail) return vw;
132
136
  const w = Math.round(rail.getBoundingClientRect ? rail.getBoundingClientRect().width : 0) || RAIL_W;
133
137
  return vw - w;
@@ -181,11 +185,15 @@ function SidebarResize() {
181
185
  // once the page has drawn its change.
182
186
  const again = () => afterPaint(applyKeptSidebarWidth);
183
187
  const offRail = usageRailStore.subscribe(again);
188
+ // #2585: a docked panel in the rail's place (it is published on every render of its editor: only its coming and
189
+ // going changes the room).
190
+ let docked = !!sidePanelStore.get().panel;
191
+ const offPanel = sidePanelStore.subscribe(() => { const now = !!sidePanelStore.get().panel; if (now !== docked) { docked = now; again(); } });
184
192
  const offNav = navStore.subscribe(again);
185
193
  const w = win();
186
194
  if (w && typeof w.addEventListener === "function") w.addEventListener("resize", applyKeptSidebarWidth);
187
195
  return () => {
188
- offRail(); offNav();
196
+ offRail(); offPanel(); offNav();
189
197
  if (w && typeof w.removeEventListener === "function") w.removeEventListener("resize", applyKeptSidebarWidth);
190
198
  if (activeDrag) activeDrag.end(false); // unmounted mid-drag: nothing kept, nothing left behind
191
199
  };
@@ -787,7 +795,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
787
795
  ${viewOnly || guest || !shell.banner ? null : html`<${shell.banner} />`}
788
796
  <${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
789
797
  </main>
790
- ${guest ? null : html`<${UsageRail} route=${nav.route} />`}
798
+ ${guest ? null : html`<${RightRail} route=${nav.route} />`}
791
799
  <${BottomTabs} route=${nav.route} viewOnly=${viewOnly} />
792
800
  ${shell.dialog ? html`<${shell.dialog.Component} ...${shell.dialog.props} onClose=${closeDialog} />` : null}
793
801
  ${viewOnly || guest || (nav.route && nav.route.panel === "settings") ? null : html`<${PendingChanges} />`}
@@ -36,7 +36,8 @@
36
36
  // tasks — the task board changed: the card numbers a write touched (SSE only: the board reads again when it is shown).
37
37
  // offices — the stored offices changed ({ rev }; SSE only: the Office view reads /ui/offices again).
38
38
  // office_skins — a skin pack was uploaded or deleted ({ rev }; SSE only: the Office view reads /ui/office-skins again).
39
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins"];
39
+ // office_acts — the agents' live acts in the Office view changed ({ acts }, #2578; a poll page reads /ui/office-acts).
40
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "reactions", "steps_tick", "clock", "traffic", "delegation", "backend_login", "tasks", "offices", "office_skins", "office_acts"];
40
41
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
41
42
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
42
43
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed
@@ -1348,6 +1348,18 @@ body.side-resizing, body.side-resizing * { cursor: col-resize !important; user-s
1348
1348
  @media (prefers-reduced-motion: no-preference) { .u-crow { transition: background-color 0.15s var(--ease); } }
1349
1349
  .u-rail-body { flex: 1; min-height: 0; overflow-y: auto; padding: 0 var(--s-3) var(--s-4); display: flex; flex-direction: column; gap: var(--s-3); }
1350
1350
  @media (max-width: 1199px) { .u-rail { display: none; } }
1351
+ /* #2585: the docked side panel (side-panel.js), in the usage rail's place beside the page — a non-modal dialog, so the
1352
+ shared dialog's head, body and actions, laid out as a full-height column. Below the rail's width it is never drawn
1353
+ (the editor is the centred Dialog there). */
1354
+ .dlg.side-panel { position: static; inset: auto; margin: 0; width: 400px; max-width: 400px; height: auto; max-height: none; flex-shrink: 0;
1355
+ display: flex; flex-direction: column; min-height: 0; border-radius: 0; box-shadow: none; border-left: 1px solid var(--border); background: var(--bg); color: var(--text); }
1356
+ .side-panel .dlg-box { flex: 1; min-height: 0; max-height: none; }
1357
+ .side-panel .dlg-head { padding: var(--s-3) var(--s-3) var(--s-3) var(--s-5); border-bottom: 1px solid var(--border); }
1358
+ .side-panel .dlg-body { flex: 1; min-height: 0; padding-top: var(--s-4); }
1359
+ .side-panel .dlg-actions { padding: var(--s-3) var(--s-5); border-top: 1px solid var(--border); }
1360
+ @media (max-width: 1199px) { .dlg.side-panel { display: none; } }
1361
+ /* A toast (Offices saved.) beside a docked panel, not over its Cancel and Save. */
1362
+ @media (min-width: 1200px) { .shell:has(> .side-panel) .toasts { right: calc(400px + var(--s-6)); } }
1351
1363
  @media (max-width: 899px) {
1352
1364
  .hide-narrow { display: none; }
1353
1365
  .v-id { padding-left: var(--s-3); padding-right: var(--s-3); }
@@ -1731,7 +1743,7 @@ strong.org-name { font-size: var(--fs-sm); color: var(--text); }
1731
1743
  .oc-office > .oc-bar { margin: 0; padding: var(--s-3) var(--s-5) var(--s-2); }
1732
1744
  .oc-office > .oc-legend { margin: 0 var(--s-5) var(--s-2); }
1733
1745
  .oo { flex: 1 0 auto; display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; width: 100%; }
1734
- .oo > :not(.oo-stage) { margin-left: var(--s-5); margin-right: var(--s-5); }
1746
+ .oo > :not(.oo-stage):not(dialog) { margin-left: var(--s-5); margin-right: var(--s-5); } /* not the editors' dialogs: they centre themselves (#2585) */
1735
1747
  .oo-bar { display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); }
1736
1748
  .oo-pick { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--text-2); }
1737
1749
  .oo-pick .tb-select { max-width: 200px; }
@@ -1839,13 +1851,16 @@ html.oo-immersive .p-fleet > .panel-body { z-index: 50; }
1839
1851
  .ol-bar { position: absolute; left: 0; right: 0; bottom: 0; z-index: 8; display: flex; flex-wrap: wrap; align-items: center; gap: var(--s-2); padding: var(--s-2);
1840
1852
  background: var(--surface); border-top: 1px solid var(--border); }
1841
1853
  .ol-actions { display: inline-flex; gap: var(--s-2); margin-left: auto; }
1854
+ /* #2585: Edit layout's controls docked in the side panel (a wide desk): one column, the waypoint's own row wrapping. */
1855
+ .ol-side { display: flex; flex-direction: column; align-items: flex-start; gap: var(--s-3); }
1856
+ .ol-side .ol-corridors { display: flex; flex-wrap: wrap; gap: var(--s-2); }
1842
1857
  .ol-corridors { display: inline-flex; flex-wrap: wrap; gap: 4px; }
1843
1858
  .ol-msg { flex-basis: 100%; margin: 0; }
1844
1859
  @media (max-width: 640px) {
1845
1860
  /* A phone: the bars at the column's narrower padding; the stage at least most of a screen; no overview (as the canvas). */
1846
1861
  .oc-office > .oc-bar { padding: var(--s-2) var(--s-3); }
1847
1862
  .oc-office > .oc-legend { margin: 0 var(--s-3) var(--s-2); }
1848
- .oo > :not(.oo-stage) { margin-left: var(--s-3); margin-right: var(--s-3); }
1863
+ .oo > :not(.oo-stage):not(dialog) { margin-left: var(--s-3); margin-right: var(--s-3); }
1849
1864
  .oo-stage { min-height: max(300px, calc(100dvh - 300px)); }
1850
1865
  .oo-pick .tb-select { max-width: 150px; }
1851
1866
  .oo-mini { display: none; }
@@ -0,0 +1,95 @@
1
+ // #2585 (the user): settings-like editors docked on the right. On a screen wide enough for the usage rail (RAIL_MEDIA,
2
+ // #1673) an editor opens in the rail's place, beside the page — not modal: the page stays in view and in use (the
3
+ // Office re-fits beside it). While one is open the rail steps aside; it comes back as it was when the panel closes (its
4
+ // shown/hidden choice is never touched). Narrower, the same editor is the shared Dialog (centred; a phone's sheet).
5
+ //
6
+ // <${SidePanel} title=… onClose=… actions=… busy=…>…body…</${SidePanel}>: rendered = open, the owner unmounts it to
7
+ // close (like Dialog). Docked, the owner renders nothing itself: it publishes its title, body and actions to the shell's
8
+ // host (sidePanelStore) on every render, so the body keeps the owner's state and closures; the owner going (a close, a
9
+ // navigation) takes the panel with it. Esc in the panel and its × close it (not while `busy`); focus moves into it when
10
+ // it opens and goes back to what opened it when it closes. Docked, the body renders under the shell, not under its
11
+ // owner: a context provided above the owner does not reach it (pass what it needs as props, or read a store).
12
+ // `dirty`: the editor holds unsaved changes. Docked, the page beside it is in use (its links, search): leaving the page
13
+ // then closes the panel as Cancel does — and with changes, asks first (the app's leave guard; one at a time).
14
+ // `dock` false keeps it from docking (the Office in fullscreen, where the shell is out of view); `fallback`, when given,
15
+ // is what it is below the rail's width (or not docked) instead of the Dialog — Edit layout's bar over the canvas.
16
+ import { html, useEffect, useLayoutEffect, useRef } from "./app-html.js";
17
+ import { createStore, useStore } from "./app-store.js";
18
+ import { t } from "./app-i18n.js";
19
+ import { Icon } from "./ui-icons.js";
20
+ import { Dialog } from "./ui-dialog.js";
21
+ import { confirmDialog } from "./ui-confirm.js";
22
+ import { navigate, setLeaveGuard } from "./app-nav.js";
23
+ import { UsageRail, useRailWidth } from "./header-tools.js";
24
+
25
+ /** { panel: { id, title, label, body, actions, busy, onClose } | null } — the one docked panel. */
26
+ export const sidePanelStore = createStore({ panel: null });
27
+ let seq = 0;
28
+ const clear = (id) => { if (sidePanelStore.get().panel?.id === id) sidePanelStore.set({ panel: null }); };
29
+
30
+ /** An editor: docked on a wide screen, the shared Dialog (`wide`) elsewhere. */
31
+ export function SidePanel({ title, label, onClose, actions, busy = false, dirty = false, dock = true, fallback, children }) {
32
+ const docked = useRailWidth() && dock;
33
+ const id = useRef(0);
34
+ if (!id.current) id.current = ++seq;
35
+ const opener = useRef(undefined);
36
+ if (opener.current === undefined) opener.current = typeof document !== "undefined" ? document.activeElement : null;
37
+ useLayoutEffect(() => {
38
+ if (docked) sidePanelStore.set({ panel: { id: id.current, title, label, body: children, actions, busy, onClose } });
39
+ else clear(id.current);
40
+ });
41
+ useLayoutEffect(() => () => {
42
+ const host = typeof document !== "undefined" ? document.querySelector(".side-panel") : null;
43
+ const inside = !!host && (host.contains(document.activeElement) || document.activeElement === document.body);
44
+ clear(id.current);
45
+ // Focus back to what opened it — when it was in the panel (or nowhere): a person who moved on keeps their place.
46
+ const back = opener.current;
47
+ if ((inside || !host) && back && typeof back.focus === "function" && document.contains?.(back)) back.focus();
48
+ }, []);
49
+ // Leaving the page with changes in the docked panel: asked in the app's dialog; yes = Cancel, then on to there.
50
+ const live = useRef(null);
51
+ live.current = { title, onClose };
52
+ useEffect(() => {
53
+ if (!docked || !dirty) return undefined;
54
+ let asking = false;
55
+ return setLeaveGuard((_to, path) => {
56
+ if (!asking) {
57
+ asking = true;
58
+ confirmDialog({ message: t("app.sidePanelLeave", live.current.title), confirmLabel: t("app.sidePanelDiscard"), danger: true }).then((yes) => {
59
+ asking = false;
60
+ if (!yes) return;
61
+ live.current.onClose();
62
+ navigate(path, { force: true });
63
+ });
64
+ }
65
+ return false;
66
+ });
67
+ }, [docked, dirty]);
68
+ if (docked) return null;
69
+ if (fallback !== undefined) return fallback;
70
+ return html`<${Dialog} title=${title} label=${label} onClose=${onClose} actions=${actions} busy=${busy} wide=${true}>${children}</${Dialog}>`;
71
+ }
72
+
73
+ /** The docked panel itself: a non-modal dialog in the rail's place. */
74
+ function SidePanelHost({ panel }) {
75
+ const box = useRef(null);
76
+ // Into the panel as it opens (its box: no ring; Tab moves on), once per panel.
77
+ useLayoutEffect(() => { if (box.current && typeof box.current.focus === "function") box.current.focus({ preventScroll: true }); }, [panel.id]);
78
+ const close = () => { if (!panel.busy) panel.onClose(); };
79
+ const onKeyDown = (e) => { if (e.key === "Escape" && !e.defaultPrevented) { e.preventDefault(); close(); } };
80
+ return html`<dialog open data-trust="dialog" class="dlg side-panel" aria-label=${panel.label || panel.title} onKeyDown=${onKeyDown}>
81
+ <div class="dlg-box" tabindex="-1" ref=${box}>
82
+ <div class="dlg-head"><h2 class="dlg-title">${panel.title}</h2>
83
+ <button type="button" class="icon-btn dlg-x" aria-label=${t("app.close")} title=${t("app.close")} onClick=${close}><${Icon} name="close" /></button></div>
84
+ <div class="dlg-body">${panel.body}</div>
85
+ ${panel.actions ? html`<div class="dlg-actions">${panel.actions}</div>` : null}
86
+ </div>
87
+ </dialog>`;
88
+ }
89
+
90
+ /** The shell's right-hand column: a docked panel when one is open (wide screens), else the usage rail. */
91
+ export function RightRail({ route }) {
92
+ const { panel } = useStore(sidePanelStore);
93
+ const wide = useRailWidth();
94
+ return panel && wide ? html`<${SidePanelHost} panel=${panel} />` : html`<${UsageRail} route=${route} />`;
95
+ }
package/dist/web-api.d.ts CHANGED
@@ -10,6 +10,7 @@ import type { WebEffectService } from "./web-effect-service.js";
10
10
  import type { ThemeStore } from "./web-theme-store.js";
11
11
  import type { OfficeStore, OfficeWriteLimiter } from "./web-office-store.js";
12
12
  import { type OfficeSkinStore } from "./web-office-skins.js";
13
+ import type { OfficeActs } from "./web-office-acts.js";
13
14
  import type { WebAppearance } from "./web-appearance.js";
14
15
  import type { SendOpts } from "./channel/types.js";
15
16
  import type { IncomingMessage, ServerResponse } from "node:http";
@@ -196,6 +197,8 @@ export interface WebApiContext {
196
197
  readonly officeStore?: OfficeStore;
197
198
  /** How often a person may save the offices (absent: no limit, as in a test without it). */
198
199
  readonly officeWrites?: OfficeWriteLimiter;
200
+ /** #2578: the agents' live acts in the Office view (office_set); absent: /ui/office-acts is not offered. */
201
+ readonly officeActs?: Pick<OfficeActs, "live">;
199
202
  /** #2513: the Office view's uploaded skin packs (web-office-skins/); absent: /ui/office-skins is not offered. */
200
203
  readonly officeSkins?: OfficeSkinStore;
201
204
  /** How often a person may upload or delete a skin pack (absent: no limit). */
package/dist/web-api.js CHANGED
@@ -2212,6 +2212,17 @@ export function handleWebRequest(req, res, url, ctx) {
2212
2212
  });
2213
2213
  return true;
2214
2214
  }
2215
+ // #2578: GET /ui/office-acts { acts }: the agents' live acts (office_set), read as the offices are (the public link
2216
+ // too, which has no stream); every change also comes as `office_acts` on the stream. Nothing here writes one.
2217
+ if (method === "GET" && path === "/ui/office-acts") {
2218
+ const acts = ctx.officeActs;
2219
+ if (!acts) {
2220
+ json(res, 404, { error: "Office acts are not kept on this fleet" });
2221
+ return true;
2222
+ }
2223
+ json(res, 200, { acts: acts.live(), now: Date.now() }); // now: the host's clock, for the page's offset
2224
+ return true;
2225
+ }
2215
2226
  if (path === "/ui/office-skins" || path.startsWith("/ui/office-skins/"))
2216
2227
  return handleOfficeSkins(req, res, url, ctx, path, method);
2217
2228
  // ── #1630: avatars and Discord server emoji, same-origin (the page never loads a platform's CDN; img-src stays 'self') ──