@songsid/agend 2.3.0-beta.16 → 2.3.0-beta.18

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 (122) hide show
  1. package/README.md +1 -1
  2. package/README.zh-TW.md +1 -1
  3. package/dist/backend/claude-code.js +13 -4
  4. package/dist/backend/claude-code.js.map +1 -1
  5. package/dist/backend/codex.js +15 -2
  6. package/dist/backend/codex.js.map +1 -1
  7. package/dist/backend-command.d.ts +2 -0
  8. package/dist/backend-command.js +3 -1
  9. package/dist/backend-command.js.map +1 -1
  10. package/dist/bug-report-cli.d.ts +30 -0
  11. package/dist/bug-report-cli.js +138 -0
  12. package/dist/bug-report-cli.js.map +1 -0
  13. package/dist/bug-report.d.ts +47 -0
  14. package/dist/bug-report.js +136 -0
  15. package/dist/bug-report.js.map +1 -0
  16. package/dist/channel/mcp-tools.js +1 -1
  17. package/dist/channel/mcp-tools.js.map +1 -1
  18. package/dist/cli.js +93 -8
  19. package/dist/cli.js.map +1 -1
  20. package/dist/config-validator.d.ts +6 -0
  21. package/dist/config-validator.js +13 -0
  22. package/dist/config-validator.js.map +1 -1
  23. package/dist/daemon.d.ts +14 -1
  24. package/dist/daemon.js +59 -3
  25. package/dist/daemon.js.map +1 -1
  26. package/dist/diagnostic-redaction.d.ts +10 -0
  27. package/dist/diagnostic-redaction.js +167 -0
  28. package/dist/diagnostic-redaction.js.map +1 -0
  29. package/dist/fleet-manager.d.ts +18 -0
  30. package/dist/fleet-manager.js +109 -8
  31. package/dist/fleet-manager.js.map +1 -1
  32. package/dist/general-knowledge/skills/bug-report/SKILL.md +31 -0
  33. package/dist/general-knowledge/skills/web-dashboard/SKILL.md +22 -1
  34. package/dist/instance-lifecycle.d.ts +8 -1
  35. package/dist/instance-lifecycle.js +68 -12
  36. package/dist/instance-lifecycle.js.map +1 -1
  37. package/dist/locale.js +55 -2
  38. package/dist/locale.js.map +1 -1
  39. package/dist/login-controller.d.ts +45 -0
  40. package/dist/login-controller.js +66 -3
  41. package/dist/login-controller.js.map +1 -1
  42. package/dist/ls-attach-resolve.d.ts +3 -2
  43. package/dist/ls-attach-resolve.js +10 -4
  44. package/dist/ls-attach-resolve.js.map +1 -1
  45. package/dist/ls-rows.d.ts +12 -0
  46. package/dist/ls-rows.js +82 -3
  47. package/dist/ls-rows.js.map +1 -1
  48. package/dist/needs-you.d.ts +10 -0
  49. package/dist/needs-you.js +2 -1
  50. package/dist/needs-you.js.map +1 -1
  51. package/dist/outbound-handlers.js +31 -5
  52. package/dist/outbound-handlers.js.map +1 -1
  53. package/dist/quickstart-api.d.ts +9 -0
  54. package/dist/quickstart-api.js +82 -0
  55. package/dist/quickstart-api.js.map +1 -1
  56. package/dist/service-installer.js +12 -0
  57. package/dist/service-installer.js.map +1 -1
  58. package/dist/settings-effect.js +15 -1
  59. package/dist/settings-effect.js.map +1 -1
  60. package/dist/setup-form.d.ts +1 -1
  61. package/dist/setup-form.js +23 -7
  62. package/dist/setup-form.js.map +1 -1
  63. package/dist/ui/backend-signin.js +297 -0
  64. package/dist/ui/chat-msg-actions.js +176 -0
  65. package/dist/ui/chat-store.js +33 -3
  66. package/dist/ui/chat-strings.js +8 -2
  67. package/dist/ui/chat-thread.js +44 -2
  68. package/dist/ui/panel-chat.js +37 -0
  69. package/dist/ui/panel-needs.js +4 -2
  70. package/dist/ui/panel-settings.js +6 -3
  71. package/dist/ui/shared/app-shell.js +2 -1
  72. package/dist/ui/shared/app-stream.js +2 -1
  73. package/dist/ui/shared/app.css +42 -0
  74. package/dist/ui/shared/app.js +2 -0
  75. package/dist/ui/shared/usage-dialog.js +26 -3
  76. package/dist/ui/shared/view-strings.js +10 -0
  77. package/dist/ui/web-terminal/terminal.html +1 -1
  78. package/dist/update-launch.d.ts +9 -0
  79. package/dist/update-launch.js +38 -12
  80. package/dist/update-launch.js.map +1 -1
  81. package/dist/usage/format-rich.js +6 -3
  82. package/dist/usage/format-rich.js.map +1 -1
  83. package/dist/usage/i18n-keys.d.ts +1 -1
  84. package/dist/usage/i18n-keys.js +2 -1
  85. package/dist/usage/i18n-keys.js.map +1 -1
  86. package/dist/usage/i18n.d.ts +13 -1
  87. package/dist/usage/i18n.js +29 -0
  88. package/dist/usage/i18n.js.map +1 -1
  89. package/dist/usage/providers.d.ts +43 -8
  90. package/dist/usage/providers.js +58 -20
  91. package/dist/usage/providers.js.map +1 -1
  92. package/dist/usage/usage-api.d.ts +2 -0
  93. package/dist/usage/usage-api.js +180 -55
  94. package/dist/usage/usage-api.js.map +1 -1
  95. package/dist/user-systemd.d.ts +24 -0
  96. package/dist/user-systemd.js +75 -0
  97. package/dist/user-systemd.js.map +1 -0
  98. package/dist/web-api.d.ts +10 -1
  99. package/dist/web-api.js +134 -5
  100. package/dist/web-api.js.map +1 -1
  101. package/dist/web-backend-login.d.ts +130 -0
  102. package/dist/web-backend-login.js +225 -0
  103. package/dist/web-backend-login.js.map +1 -0
  104. package/dist/web-chat-history.d.ts +31 -0
  105. package/dist/web-chat-history.js +49 -1
  106. package/dist/web-chat-history.js.map +1 -1
  107. package/dist/web-commands.d.ts +5 -1
  108. package/dist/web-commands.js +1 -1
  109. package/dist/web-commands.js.map +1 -1
  110. package/dist/web-guest-access.d.ts +3 -2
  111. package/dist/web-guest-access.js +10 -6
  112. package/dist/web-guest-access.js.map +1 -1
  113. package/dist/windows-shortcuts.d.ts +78 -0
  114. package/dist/windows-shortcuts.js +203 -0
  115. package/dist/windows-shortcuts.js.map +1 -0
  116. package/launcher/agend-install.cjs +548 -0
  117. package/launcher/install-admission.cjs +4 -39
  118. package/launcher/postinstall.cjs +14 -12
  119. package/package.json +3 -2
  120. package/scripts/preinstall-guard.cjs +2 -5
  121. package/templates/windows-shortcuts.ps1 +181 -0
  122. package/launcher/old-updater-note.cjs +0 -48
@@ -0,0 +1,176 @@
1
+ // #2272: a chat message's own actions, beyond the hover strip — the message menu (right-click, long-press on a phone),
2
+ // swipe left to reply (as in Telegram), and R on a focused message. One install per thread element.
3
+ //
4
+ // - Right-click on a message opens Reply / Copy text / Copy link. Shift+right-click, a right-click with text selected,
5
+ // or one on a link, an image or a field keeps the browser's own menu.
6
+ // - A long press (500 ms without moving) opens the same menu; the click and the browser menu it would also cause are
7
+ // swallowed.
8
+ // - Swipe left on a message: it follows the finger (up to 64 px); let go past 56 px and it is a reply. A mostly
9
+ // vertical move is scrolling, never a reply. A swipe right is the phone drawer's (app-swipe.js), on a message too: the
10
+ // two never fight.
11
+ // - R (no modifier) on a focused message replies; ↑/↓ move to the message before or after (one message is in the Tab
12
+ // order); Escape closes the menu.
13
+
14
+ const LONG_PRESS_MS = 500, MOVE_TOLERANCE = 10, SWIPE_REPLY_PX = 56, SWIPE_MAX_PX = 64;
15
+
16
+ /** The message element an event is on (a person's or an agent's message of this thread), or null. */
17
+ function messageEl(list, target) {
18
+ const el = target && target.closest ? target.closest(".msg[data-key]") : null;
19
+ return el && list.contains(el) ? el : null;
20
+ }
21
+ /** The browser's own menu stays for: Shift, selected text, a link, an image, a field, code. */
22
+ function keepsNativeMenu(e, doc) {
23
+ if (e.shiftKey) return true;
24
+ const sel = doc.getSelection ? doc.getSelection() : null;
25
+ if (sel && !sel.isCollapsed && String(sel).trim()) return true;
26
+ return !!(e.target && e.target.closest && e.target.closest("a[href], img, video, input, textarea, select, pre, code, [contenteditable='true']"));
27
+ }
28
+
29
+ /**
30
+ * Install the gestures on `list` (the thread element). `deps`:
31
+ * messageOf(key) → the row; reply(row); copyText(text) → Promise<boolean>; linkOf(row) → string; notice(text);
32
+ * canReply() → boolean (false: no Reply — a page that cannot send); t(key).
33
+ * Returns a function that removes everything (and closes an open menu).
34
+ */
35
+ export function installMessageActions(list, deps) {
36
+ const doc = list.ownerDocument, win = doc.defaultView;
37
+ let menu = null, press = null, swipe = null, swallowClick = false;
38
+
39
+ function closeMenu() {
40
+ if (!menu) return;
41
+ menu.remove(); menu = null;
42
+ doc.removeEventListener("pointerdown", outside, true);
43
+ doc.removeEventListener("keydown", onMenuKey, true);
44
+ win?.removeEventListener("scroll", closeMenu, true);
45
+ win?.removeEventListener("blur", closeMenu);
46
+ }
47
+ function outside(e) { if (menu && !menu.contains(e.target)) closeMenu(); }
48
+ function onMenuKey(e) {
49
+ if (!menu) return;
50
+ if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); closeMenu(); return; }
51
+ if (e.key === "ArrowDown" || e.key === "ArrowUp") {
52
+ e.preventDefault();
53
+ const items = [...menu.querySelectorAll("button")], i = items.indexOf(doc.activeElement);
54
+ items[(i + (e.key === "ArrowDown" ? 1 : items.length - 1)) % items.length]?.focus();
55
+ }
56
+ }
57
+ /** The message menu at (x, y), within the window; focus on its first item. */
58
+ function openMenu(row, x, y) {
59
+ closeMenu();
60
+ const items = [];
61
+ if (deps.canReply?.() !== false) items.push(["reply", deps.t("chat.reply"), () => deps.reply(row)]);
62
+ items.push(["copyText", deps.t("chat.copyText"), async () => { const ok = await deps.copyText(String(row.click?.label ?? row.text ?? "")); deps.notice(deps.t(ok ? "chat.copied" : "chat.copyFailed"), ok); }]);
63
+ items.push(["copyLink", deps.t("chat.copyLink"), async () => { const ok = await deps.copyText(deps.linkOf(row)); deps.notice(deps.t(ok ? "chat.copied" : "chat.copyFailed"), ok); }]);
64
+ menu = doc.createElement("div");
65
+ menu.className = "msg-menu";
66
+ menu.setAttribute("role", "menu");
67
+ menu.setAttribute("aria-label", deps.t("chat.msgMenu"));
68
+ menu.setAttribute("data-trust", "menu");
69
+ for (const [id, label, run] of items) {
70
+ const b = doc.createElement("button");
71
+ b.type = "button"; b.className = "menu-item"; b.setAttribute("role", "menuitem"); b.dataset.item = id; b.textContent = label;
72
+ b.addEventListener("click", () => { closeMenu(); void run(); });
73
+ menu.append(b);
74
+ }
75
+ doc.body.append(menu);
76
+ const w = win?.innerWidth ?? 1024, h = win?.innerHeight ?? 768, r = menu.getBoundingClientRect();
77
+ menu.style.left = `${Math.max(4, Math.min(x, w - r.width - 4))}px`;
78
+ menu.style.top = `${Math.max(4, Math.min(y, h - r.height - 4))}px`;
79
+ doc.addEventListener("pointerdown", outside, true);
80
+ doc.addEventListener("keydown", onMenuKey, true);
81
+ win?.addEventListener("scroll", closeMenu, true);
82
+ win?.addEventListener("blur", closeMenu);
83
+ menu.querySelector("button")?.focus();
84
+ }
85
+
86
+ function onContextMenu(e) {
87
+ if (swallowClick) { e.preventDefault(); return; } // the long press already opened ours
88
+ cancelPress(); // Android's long press fires this itself: one menu
89
+ const el = messageEl(list, e.target);
90
+ if (!el || keepsNativeMenu(e, doc)) return;
91
+ const row = deps.messageOf(el.dataset.key);
92
+ if (!row) return;
93
+ e.preventDefault();
94
+ openMenu(row, e.clientX, e.clientY);
95
+ }
96
+ function onKeyDown(e) {
97
+ // ↑/↓ on a focused message: the one before or after it (one message is in the Tab order: the thread's rove()).
98
+ if ((e.key === "ArrowUp" || e.key === "ArrowDown") && !e.altKey && !e.ctrlKey && !e.metaKey && !e.shiftKey
99
+ && e.target && e.target.matches && e.target.matches(".msg[data-key]") && list.contains(e.target)) {
100
+ const all = [...list.querySelectorAll(".msg[data-key]")], i = all.indexOf(e.target);
101
+ const to = all[i + (e.key === "ArrowDown" ? 1 : -1)];
102
+ if (to) { e.preventDefault(); deps.rove?.(to.dataset.key); to.focus(); to.scrollIntoView?.({ block: "nearest" }); }
103
+ return;
104
+ }
105
+ if (e.key !== "r" && e.key !== "R") return;
106
+ if (e.ctrlKey || e.metaKey || e.altKey || deps.canReply?.() === false) return;
107
+ const el = e.target && e.target.matches && e.target.matches(".msg[data-key]") && list.contains(e.target) ? e.target : null;
108
+ const row = el ? deps.messageOf(el.dataset.key) : null;
109
+ if (!row) return;
110
+ e.preventDefault();
111
+ deps.reply(row);
112
+ }
113
+
114
+ function onTouchStart(e) {
115
+ if (e.touches.length !== 1) { cancelPress(); cancelSwipe(); return; }
116
+ const el = messageEl(list, e.target);
117
+ if (!el) return;
118
+ const t0 = e.touches[0];
119
+ const native = keepsNativeMenu({ target: e.target, shiftKey: false }, doc); // a link, an image, code: the browser's
120
+ press = native ? null : { el, x: t0.clientX, y: t0.clientY, timer: setTimeout(() => {
121
+ const row = deps.messageOf(el.dataset.key);
122
+ press = null; cancelSwipe();
123
+ if (!row) return;
124
+ swallowClick = true; setTimeout(() => { swallowClick = false; }, 700);
125
+ openMenu(row, t0.clientX, t0.clientY);
126
+ }, LONG_PRESS_MS) };
127
+ swipe = deps.canReply?.() === false || native ? null : { el, x: t0.clientX, y: t0.clientY, dx: 0, locked: null };
128
+ }
129
+ function onTouchMove(e) {
130
+ const p = e.touches[0];
131
+ if (press && (Math.abs(p.clientX - press.x) > MOVE_TOLERANCE || Math.abs(p.clientY - press.y) > MOVE_TOLERANCE)) cancelPress();
132
+ if (!swipe) return;
133
+ const dx = p.clientX - swipe.x, dy = p.clientY - swipe.y;
134
+ if (swipe.locked === null && (Math.abs(dx) > MOVE_TOLERANCE || Math.abs(dy) > MOVE_TOLERANCE)) swipe.locked = dx < 0 && Math.abs(dy) < -dx / 2 ? "x" : "no";
135
+ if (swipe.locked !== "x") return;
136
+ swipe.dx = Math.max(0, Math.min(-dx, SWIPE_MAX_PX)); // how far left, as a positive distance
137
+ swipe.el.style.transform = `translateX(${-swipe.dx}px)`;
138
+ swipe.el.classList.toggle("swipe-ready", swipe.dx >= SWIPE_REPLY_PX);
139
+ }
140
+ function onTouchEnd() {
141
+ cancelPress();
142
+ if (!swipe) return;
143
+ const { el, dx, locked } = swipe;
144
+ cancelSwipe();
145
+ if (locked === "x" && dx >= SWIPE_REPLY_PX) { const row = deps.messageOf(el.dataset.key); if (row) deps.reply(row); }
146
+ }
147
+ function cancelPress() { if (press) { clearTimeout(press.timer); press = null; } }
148
+ function cancelSwipe() {
149
+ if (!swipe) return;
150
+ swipe.el.style.transform = ""; swipe.el.classList.remove("swipe-ready");
151
+ swipe = null;
152
+ }
153
+ function onClickCapture(e) { if (swallowClick) { e.preventDefault(); e.stopPropagation(); } }
154
+
155
+ // A message focused by a click or a tap becomes the one Tab comes back to.
156
+ function onFocusIn(e) { const el = e.target && e.target.matches && e.target.matches(".msg[data-key]") ? e.target : null; if (el && list.contains(el)) deps.rove?.(el.dataset.key); }
157
+ list.addEventListener("focusin", onFocusIn);
158
+ list.addEventListener("contextmenu", onContextMenu);
159
+ list.addEventListener("keydown", onKeyDown);
160
+ list.addEventListener("touchstart", onTouchStart, { passive: true });
161
+ list.addEventListener("touchmove", onTouchMove, { passive: true });
162
+ list.addEventListener("touchend", onTouchEnd, { passive: true });
163
+ list.addEventListener("touchcancel", onTouchEnd, { passive: true });
164
+ list.addEventListener("click", onClickCapture, true);
165
+ return () => {
166
+ closeMenu(); cancelPress(); cancelSwipe();
167
+ list.removeEventListener("focusin", onFocusIn);
168
+ list.removeEventListener("contextmenu", onContextMenu);
169
+ list.removeEventListener("keydown", onKeyDown);
170
+ list.removeEventListener("touchstart", onTouchStart);
171
+ list.removeEventListener("touchmove", onTouchMove);
172
+ list.removeEventListener("touchend", onTouchEnd);
173
+ list.removeEventListener("touchcancel", onTouchEnd);
174
+ list.removeEventListener("click", onClickCapture, true);
175
+ };
176
+ }
@@ -42,7 +42,7 @@ const COMPACT_PAGE_LIMIT_MS = 11 * 60_000;
42
42
  /** Build a store. `deps` (fetch, toast, announce, t, now) are injected so tests can drive every path. */
43
43
  export function createChatStore(deps) {
44
44
  const s = {
45
- msgs: {}, drafts: {}, failedSends: {}, pendingFiles: {}, sending: {}, inFlightFiles: {}, sendFailures: {}, instanceStatus: {}, waking: {},
45
+ msgs: {}, drafts: {}, replies: {}, failedSends: {}, pendingFiles: {}, sending: {}, inFlightFiles: {}, sendFailures: {}, instanceStatus: {}, waking: {},
46
46
  prompts: {}, workingSince: {}, stopping: {}, cancelling: {}, scrollMemo: {}, historyRead: new Set(), rbBusy: new Set(),
47
47
  pollMine: readPollMine(),
48
48
  // #1823 (#1828 review): the server's clock minus this device's. A poll's deadline is the server's; a page whose clock
@@ -250,6 +250,7 @@ export function createChatStore(deps) {
250
250
  }
251
251
  async function send(target) {
252
252
  const txt = (s.drafts[target] || "").trim();
253
+ const quote = s.replies[target]; // #2272: the message this one replies to (its row; the server reads the rest)
253
254
  const files = (s.pendingFiles[target] || []).slice();
254
255
  if ((!txt && !files.length) || !target || s.sending[target]) return;
255
256
  s.sending[target] = true;
@@ -263,12 +264,16 @@ export function createChatStore(deps) {
263
264
  try {
264
265
  const ids = [];
265
266
  for (const f of files) ids.push((await uploadFile(target, f)).id);
266
- r = await api("POST", "/ui/send", ids.length ? { instance: target, message: txt, attachments: ids } : { instance: target, message: txt });
267
+ const body = ids.length ? { instance: target, message: txt, attachments: ids } : { instance: target, message: txt };
268
+ if (quote) body.replyTo = { boot: quote.boot, id: quote.id };
269
+ r = await api("POST", "/ui/send", body);
267
270
  } catch (err) { r = { error: err && err.message ? err.message : t("chat.disconnected") }; }
268
271
  s.sending[target] = false;
269
272
  delete s.waking[target];
270
273
  delete s.inFlightFiles[target];
271
- if (r && !r.error) { changed(target); return; }
274
+ if (r && !r.error) { if (s.replies[target] === quote) delete s.replies[target]; changed(target); return; }
275
+ // #2272: the quoted message is gone: the reply goes, the text and files stay (sent again without it, or re-aimed).
276
+ if (r && r.reason === "reply_gone" && s.replies[target] === quote) delete s.replies[target];
272
277
  if (files.length) s.pendingFiles[target] = R().checkFiles(files, s.pendingFiles[target] || []).kept;
273
278
  if (txt) {
274
279
  const shown = s.current === target;
@@ -331,6 +336,30 @@ export function createChatStore(deps) {
331
336
  function discardFailed(target) { delete s.failedSends[target]; changed(target); }
332
337
  /** Typing stays local, except a failed send's Retry button must follow edits too. */
333
338
  function setDraft(target, value) { s.drafts[target] = value; if (s.sendFailures[target]) changed(target); }
339
+ /**
340
+ * #2272: reply to a message of this chat — what the bar above the composer shows (its sender and first words); only
341
+ * its row is sent. One at a time per chat: a new one replaces it.
342
+ */
343
+ function startReply(target, m) {
344
+ if (!target || !m || typeof m.boot !== "string" || typeof m.id !== "number") return;
345
+ s.replies[target] = { boot: m.boot, id: m.id, sender: String(m.sender || ""), text: String(m.click?.label ?? m.text ?? "").replace(/\s+/g, " ").trim().slice(0, 160),
346
+ media: (m.attachments || []).length ? ((m.attachments || []).some(a => a.kind === "photo") ? "photo" : "document") : undefined };
347
+ changed(target);
348
+ }
349
+ function clearReply(target) { if (s.replies[target]) { delete s.replies[target]; changed(target); } }
350
+ /**
351
+ * #2272: the quoted message, drawn for a jump: in what this chat holds, or after reading the longest history the
352
+ * server keeps for it (the newest 500). false: no longer kept.
353
+ */
354
+ async function findQuoted(name, key) {
355
+ const has = () => (s.msgs[name] || []).some(m => `${m.boot}-${m.id}` === key);
356
+ if (has()) return true;
357
+ try {
358
+ const r = await api("GET", `/ui/history?instance=${encodeURIComponent(name)}&limit=500`);
359
+ if (r && Array.isArray(r.messages)) { s.msgs[name] = R().mergeMessages(s.msgs[name], r.messages, 500); changed(name, "msgs"); }
360
+ } catch { /* the jump says it is not there */ }
361
+ return has();
362
+ }
334
363
 
335
364
  // ── Stop the reply under way (the composer's Stop, or Esc). Set before the request goes, so a held Esc or a second
336
365
  // click sends nothing more (#1317); "Stopping…" until the agent goes idle, or a minute passes.
@@ -425,6 +454,7 @@ export function createChatStore(deps) {
425
454
  onPrompt, applyPrompts, resolvePrompt, answerPrompt, answerByNonce, holdPrompt, releasePrompt, promptsFor,
426
455
  applyReplyButtons, clickReplyButton,
427
456
  uploadFile, send, canRetrySend, retrySend, addFiles, removeFile, attachPastedText, fileBackAsText, isPasted, putBack, discardFailed, setDraft,
457
+ startReply, clearReply, findQuoted,
428
458
  cancelReply, isUser, runCommand, dismissCommand, commandFree,
429
459
  };
430
460
  }
@@ -10,6 +10,9 @@ register("chat", {
10
10
  sendFailure_instance_stopped: "This instance was stopped or removed. Start it on the host before retrying.",
11
11
  sendFailure_delivery_unavailable: "The instance could not receive this message. Check its status on the host, then retry.",
12
12
  sendFailure_delivery_changed: "The session or instance changed before this message was sent. Sign in again if needed, then retry.",
13
+ sendFailure_reply_gone: "The message you replied to is no longer in this chat. Your text is still here: send it without the reply, or reply to another message.",
14
+ reply: "Reply", replyingTo: "Replying to {0}", replyCancel: "Cancel the reply (Esc)", replyHidden: "an earlier message", replyPhoto: "[Photo]", replyFile: "[File]",
15
+ replyGone: "That message is no longer in this chat.", replyJump: "Show the message this replies to", copyText: "Copy text", copyLink: "Copy link", msgMenu: "Message",
13
16
 
14
17
  effectTrialReason_checking: "Checking this session…",
15
18
  effectTrialReason_localAdmin: "Sign in as an admin outside the public link to Try.",
@@ -35,7 +38,7 @@ register("chat", {
35
38
  compactTimeout: "No sign that compaction finished within {0} minutes — check the agent's own chat.",
36
39
  compactLost: "Can't follow this compaction: the command did not reach the agent, or the agent stopped or changed backend.",
37
40
  compactNotCompacted: "kiro did not compact the conversation: it was too short, or the compaction was stopped.",
38
- cmdChoose: "Choose", cmdCurrent: "current", chipCompact: "Compact", chipClear: "Clear…", chipContext: "Context {0}% used",
41
+ cmdChoose: "Choose", cmdCurrent: "current", cmdSignIn: "Sign in", cmdSignInTo: "Sign in to {0}", chipCompact: "Compact", chipClear: "Clear…", chipContext: "Context {0}% used",
39
42
  chipModel: "Model: {0} — choose another", chipEffort: "Effort: {0} — choose another",
40
43
  cmd_ctx: "Context use, model and effort", cmd_compact: "Compact the conversation", cmd_clear: "Clear the conversation (asks first)",
41
44
  cmd_model: "Show or switch the model", cmd_backend: "Switch the CLI backend, then model and effort (restarts)", cmd_effort: "Show or set the reasoning effort", cmd_cancel: "Stop the current reply",
@@ -144,6 +147,9 @@ register("chat", {
144
147
  sendFailure_instance_stopped: "這個實例已停止或移除。請先在主機啟動它,再重試。",
145
148
  sendFailure_delivery_unavailable: "實例目前無法接收這則訊息。請在主機檢查它的狀態,再重試。",
146
149
  sendFailure_delivery_changed: "訊息送出前,登入或實例狀態已改變。必要時請重新登入,再重試。",
150
+ sendFailure_reply_gone: "你回覆的訊息已不在這個聊天裡。內容還在:可以不帶回覆直接送出,或改回覆其他訊息。",
151
+ reply: "回覆", replyingTo: "回覆 {0}", replyCancel: "取消回覆(Esc)", replyHidden: "之前的一則訊息", replyPhoto: "[圖片]", replyFile: "[檔案]",
152
+ replyGone: "那則訊息已不在這個聊天裡。", replyJump: "顯示被回覆的訊息", copyText: "複製文字", copyLink: "複製連結", msgMenu: "訊息",
147
153
 
148
154
  effectTrialReason_checking: "正在確認工作階段…",
149
155
  effectTrialReason_localAdmin: "請在公開連結以外的儀表板以管理員登入後試播。",
@@ -169,7 +175,7 @@ register("chat", {
169
175
  compactTimeout: "{0} 分鐘內沒有看到壓縮完成的紀錄,請到代理自己的對話確認。",
170
176
  compactLost: "無法再追蹤這次壓縮:指令沒有送到代理,或代理已停止、換了後端。",
171
177
  compactNotCompacted: "kiro 沒有壓縮對話:對話太短,或壓縮被中止了。",
172
- cmdChoose: "選擇", cmdCurrent: "目前", chipCompact: "壓縮", chipClear: "清空…", chipContext: "已用 {0}% 的 context",
178
+ cmdChoose: "選擇", cmdCurrent: "目前", cmdSignIn: "登入", cmdSignInTo: "登入 {0}", chipCompact: "壓縮", chipClear: "清空…", chipContext: "已用 {0}% 的 context",
173
179
  chipModel: "模型:{0}——選擇其他", chipEffort: "推理強度:{0}——選擇其他",
174
180
  cmd_ctx: "context 用量、模型與推理強度", cmd_compact: "壓縮對話", cmd_clear: "清空對話(會先確認)",
175
181
  cmd_model: "查看或切換模型", cmd_backend: "切換 CLI backend,再選模型和推理強度(會重新啟動)", cmd_effort: "查看或設定推理強度", cmd_cancel: "停止目前的回覆",
@@ -28,6 +28,7 @@ const P = () => globalThis.AgendPreview;
28
28
  /** #1672: a status line longer than this (lines, or characters) is folded until it is opened. */
29
29
  export const STATUS_FOLD_LINES = 6;
30
30
  export const STATUS_FOLD_CHARS = 600;
31
+ const REPLY = '<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"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg>';
31
32
  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>';
32
33
 
33
34
  /** Escapes & " ' < >: safe for text and inside a quoted attribute. */
@@ -141,7 +142,22 @@ export function createThread(list, scroller, opts) {
141
142
  const buttons = x.role === "agent" ? replyButtonsHtml(x.buttons) : "";
142
143
  // A person's click on a reply button shows as the button's label, never the line the agent was given (its value).
143
144
  const clicked = x.role === "user" && x.click && typeof x.click.label === "string";
144
- return `<div class="msg ${u ? "user" : "agent"}"><div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div><div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, 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>`;
145
+ // #2272: reply to it — a hover strip at its top right (phones: swipe left or long-press; R on a focused message).
146
+ const key = msgKey(x);
147
+ const act = opts.canReply?.() === false ? "" : `<div data-trust="thread-control" class="msg-actions"><button type="button" class="chip-btn icon-only" data-act="reply" data-arg="${escAttr(key)}" title="${escAttr(tr("chat.reply"))}" aria-label="${escAttr(tr("chat.reply"))}">${REPLY}</button></div>`;
148
+ return `<div class="msg ${u ? "user" : "agent"}" data-key="${escAttr(key)}" tabindex="-1">${act}<div class="meta"><span class="sender">${escAttr(x.sender)}</span>${time}${ticks}</div>${quoteHtml(x.replyTo)}<div class="body">${clicked ? clickHtml(x.click) : `<div class="md">${renderedMarkdown(x.text, x.role === "agent", opts.emojis, 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>`;
149
+ }
150
+ /**
151
+ * #2272: what a reply quotes, above its body — the quoted sender and first words (or that it had files), as the server
152
+ * read them when it was sent; a button that shows that message. A guest that may not see it gets only "an earlier
153
+ * message". Text only: everything is escaped.
154
+ */
155
+ function quoteHtml(q) {
156
+ if (!q || typeof q !== "object" || typeof q.boot !== "string" || typeof q.id !== "number") return "";
157
+ const words = q.hidden ? tr("chat.replyHidden")
158
+ : (typeof q.text === "string" && q.text) || (q.media === "photo" ? tr("chat.replyPhoto") : q.media === "document" ? tr("chat.replyFile") : "");
159
+ const who = !q.hidden && typeof q.sender === "string" && q.sender ? `<span class="quote-sender">${escAttr(q.sender)}</span> ` : "";
160
+ return `<button type="button" data-trust="thread-control" class="msg-quote" data-act="jumpQuote" data-arg="${escAttr(`${q.boot}-${q.id}`)}" title="${escAttr(tr("chat.replyJump"))}"><span class="quote-mark">${REPLY}</span> ${who}<span class="quote-text">${escAttr(words)}</span></button>`;
145
161
  }
146
162
  /**
147
163
  * A button click as a chip: 🔘 and the label (escaped: the label is the agent's text). With `of` (live, while its
@@ -656,8 +672,20 @@ export function createThread(list, scroller, opts) {
656
672
  }
657
673
  armDay();
658
674
  armPolls();
675
+ rove();
659
676
  return added;
660
677
  }
678
+ /**
679
+ * #2272 (review): one message in the thread is in the Tab order — the last one focused while it is still drawn, else
680
+ * the newest; ↑/↓ move between messages (chat-msg-actions.js). The rest are -1, so Tab passes the thread in one step.
681
+ */
682
+ let roving = null;
683
+ function rove(key) {
684
+ if (key !== undefined) roving = key;
685
+ const at = (roving && nodes.has(roving) ? nodes.get(roving).node : null) || [...list.querySelectorAll(".msg[data-key]")].at(-1) || null;
686
+ for (const el of list.querySelectorAll('.msg[data-key][tabindex="0"]')) if (el !== at) el.setAttribute("tabindex", "-1");
687
+ if (at) at.setAttribute("tabindex", "0");
688
+ }
661
689
 
662
690
  function onScroll() {
663
691
  // #2158: the thread got shorter under a pinned view (text settling on a cold load, a card) and the browser clamped
@@ -742,7 +770,21 @@ export function createThread(list, scroller, opts) {
742
770
  if (trialStatus(row) === "ready" && opts.canTryEffect?.(row) === true) opts.startEffectTrial?.(row);
743
771
  else opts.fixEffectTrial?.(row, trialStatus(row));
744
772
  } else if (act === "showButtons") { const x = msgs.find(y => y.buttons && y.buttons.id === el.dataset.arg); if (x) reveal(msgKey(x)); }
773
+ else if (act === "reply") { const x = msgs.find(y => msgKey(y) === el.dataset.arg); if (x) opts.startReply?.(x); }
774
+ else if (act === "jumpQuote") void jumpTo(el.dataset.arg);
745
775
  }
776
+ /**
777
+ * #2272: show the message a reply quotes, flashed — drawn now, or after the chat reads back the longest history the
778
+ * server keeps; otherwise it says it is no longer there.
779
+ */
780
+ async function jumpTo(key) {
781
+ if (typeof key !== "string" || !key) return;
782
+ if (reveal(key)) return;
783
+ const found = opts.findQuoted ? await opts.findQuoted(key) : false;
784
+ if (!found || !reveal(key)) opts.notice?.(tr("chat.replyGone"));
785
+ }
786
+ /** #2272: a drawn message's row (for the message menu and gestures), or undefined. */
787
+ function messageOf(key) { return msgs.find(y => msgKey(y) === key); }
746
788
 
747
789
  /** The thread goes (the chat left, or another instance opened): every preview in it stops first. */
748
790
  /**
@@ -785,5 +827,5 @@ export function createThread(list, scroller, opts) {
785
827
  armPolls();
786
828
  }
787
829
 
788
- return { render, onScroll, onClick, jumpLatest, reveal, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
830
+ return { render, onScroll, onClick, jumpLatest, reveal, jumpTo, messageOf, rove, refreshCards, park, attach, dispose, nodes, get unseen() { return unseen; } };
789
831
  }
@@ -66,6 +66,7 @@ import "./chat-strings.js";
66
66
  import { confirmedWrite, selfConfirmed } from "./settings-confirm.js";
67
67
  import { createChatStore } from "./chat-store.js";
68
68
  import { createThread, msgKey } from "./chat-thread.js";
69
+ import { installMessageActions } from "./chat-msg-actions.js";
69
70
  import { createEmojiIndex } from "./chat-emojis.js";
70
71
  import { pollPublicPreview } from "./preview-status.js";
71
72
  import { createMentionIndex } from "./chat-mentions.js";
@@ -419,6 +420,10 @@ function threadEntry(name, doc) {
419
420
  canTryEffect: x => getAppEffects()?.canTry(x) === true,
420
421
  startEffectTrial: x => { const seq = navStore.get().seq; void getAppEffects()?.startTrial(x).then(ok => { if (!ok && e.sink && navStore.get().seq === seq && document.hasFocus?.() === true && document.visibilityState === "visible") toast(t("chat.effectTrialUnavailable")); }); },
421
422
  clickReplyButton: (id, index) => store.clickReplyButton(name, id, index), // #1266
423
+ // #2272: reply to a message (the bar above the composer), and show the one a reply quotes.
424
+ startReply: (x) => { store.startReply(name, x); doc.getElementById("msgIn")?.focus(); },
425
+ findQuoted: (key) => store.findQuoted(name, key),
426
+ notice: (text) => toast(text, false),
422
427
  replyButtonBusy: (id) => store.state.rbBusy.has(id),
423
428
  pollMine: (id) => store.state.pollMine[id], // #1823: this device's own vote
424
429
  serverNow: () => Date.now() + store.state.clockSkew, // #1823: the deadline is the server's
@@ -484,8 +489,18 @@ function Thread({ name, th }) {
484
489
  const onScroll = () => { store.state.scrollMemo[name] = thread.onScroll(); };
485
490
  sc.addEventListener("scroll", onScroll, { passive: true });
486
491
  ls.addEventListener("click", thread.onClick);
492
+ // #2272: the message menu, swipe left / long-press on a phone, R on a focused message.
493
+ const offActions = installMessageActions(ls, {
494
+ t, messageOf: thread.messageOf, rove: thread.rove, reply: (x) => { store.startReply(name, x); document.getElementById("msgIn")?.focus(); },
495
+ copyText, notice: (text, ok = true) => toast(text, ok),
496
+ linkOf: (x) => `${location.origin}/ui/chat/${encodeURIComponent(name)}#m=${msgKey(x)}`,
497
+ });
498
+ // A copied message link (#m=<boot>-<id>): show that message, flashed.
499
+ const linked = /^#m=([0-9a-f]{1,32}-\d{1,15})$/.exec(location.hash);
500
+ if (linked) void thread.jumpTo(linked[1]);
487
501
  return () => {
488
502
  unfollow();
503
+ offActions();
489
504
  sc.removeEventListener("scroll", onScroll);
490
505
  ls.removeEventListener("click", thread.onClick);
491
506
  e.sink = null;
@@ -525,6 +540,13 @@ function Dock({ name, inst, lease, exec, awaiting, workingLabel }) {
525
540
  </div></div>`;
526
541
  }
527
542
 
543
+ /** #2233: the public link's page — the dashboard's Sign in is refused there, so it is never offered (#2262 review). */
544
+ const publicLinkPage = () => typeof document !== "undefined" && !!document.body && document.body.dataset.publicLink === "1";
545
+ /** #2233: a backend's "not signed in" choice offers Sign in (the module behind the session gate, loaded when asked). */
546
+ function openBackendSignIn(backend) {
547
+ import("./backend-signin.js").then((m) => m.openSignIn({ backend }), () => toast(t("app.loadFailed"), false));
548
+ }
549
+
528
550
  /** #1269: what the last chat command answered — its text, why it was refused, or a list to choose from. */
529
551
  function CommandCard({ name, lease }) {
530
552
  const c = store.state.commands[name];
@@ -543,6 +565,8 @@ function CommandCard({ name, lease }) {
543
565
  ${c.choices ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdChoose")}>${c.choices.options.map((o) => html`<button key=${o.id} type="button"
544
566
  class=${`btn btn-sm${o.id === c.choices.current ? " current" : ""}`} aria-current=${o.id === c.choices.current ? "true" : undefined}
545
567
  disabled=${!!o.disabled} title=${o.disabled || undefined} onClick=${() => { if (!o.disabled) run(o.id); }}>${o.label}</button>`)}</div>` : null}
568
+ ${c.choices && c.choices.options.some((o) => o.signIn) && !publicLinkPage() ? html`<div class="cmd-choices" role="group" aria-label=${t("chat.cmdSignIn")}>${c.choices.options.filter((o) => o.signIn).map((o) => html`<button
569
+ key=${`signin:${o.signIn}`} type="button" class="btn btn-sm btn-primary" onClick=${() => openBackendSignIn(o.signIn)}>${t("chat.cmdSignInTo", o.signIn)}</button>`)}</div>` : null}
546
570
  </div>`;
547
571
  }
548
572
 
@@ -589,6 +613,12 @@ function WorkBar({ name, lease, exec, awaiting, workingLabel }) {
589
613
  : html`<span class="elapsed" title=${t("chat.elapsedTitle")} aria-hidden="true">${since == null ? "" : R().formatElapsed(performance.now() - since)}</span>`}</div>`;
590
614
  }
591
615
 
616
+ /** #2272: the reply bar's quote — the first ~80 characters, or what it had instead of text. */
617
+ function replySnippet(r) {
618
+ const text = r.text ? (r.text.length > 80 ? `${r.text.slice(0, 79).trimEnd()}…` : r.text) : "";
619
+ return text || (r.media === "photo" ? t("chat.replyPhoto") : r.media === "document" ? t("chat.replyFile") : "");
620
+ }
621
+
592
622
  function Composer({ name, lease, busy }) {
593
623
  const s = store.state;
594
624
  const input = useRef(null), fileIn = useRef(null);
@@ -597,6 +627,7 @@ function Composer({ name, lease, busy }) {
597
627
  const [closedFor, setClosedFor] = useState(null); // the draft the palette was closed on (Esc); it opens again on change
598
628
  const value = s.drafts[name] || "";
599
629
  const files = s.pendingFiles[name] || [];
630
+ const reply = s.replies[name]; // #2272: the message this one will reply to
600
631
  const content = !!value.trim() || files.length > 0;
601
632
  // A file waiting: what goes is a message with its file, never a command (no palette, no command line). A guest never
602
633
  // runs one (#1920: the server refuses its slash text), so it has no palette either.
@@ -637,6 +668,7 @@ function Composer({ name, lease, busy }) {
637
668
  }
638
669
  if (pick && e.key === "Tab" && !e.shiftKey) { e.preventDefault(); complete(pick); return; }
639
670
  if (palette && e.key === "Escape" && !e.isComposing) { e.preventDefault(); setClosedFor(value); return; }
671
+ if (reply && e.key === "Escape" && !e.isComposing) { e.preventDefault(); store.clearReply(name); return; } // #2272
640
672
  if (R().composerKey(e) === "send") { e.preventDefault(); submit(); }
641
673
  };
642
674
  const onInput = (e) => { setValue(e.target.value); };
@@ -655,6 +687,11 @@ function Composer({ name, lease, busy }) {
655
687
  <span class="cmd-name mono">/${c.name}${c.arg ? html` <span class="cmd-arg">${c.arg}</span>` : null}</span><span class="cmd-desc">${t(`chat.cmd_${c.name}`)}</span></li>`)
656
688
  : html`<li class="cmd-none" role="option" aria-selected="false">${t("chat.cmdNotCommand")}</li>`}
657
689
  </ul>` : null}
690
+ ${reply ? html`<div class="reply-bar" data-trust="composer" role="status">
691
+ <span class="reply-mark" aria-hidden="true"><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"><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/></svg></span><span class="reply-label">${t("chat.replyingTo", reply.sender)}</span>
692
+ <span class="reply-text">${replySnippet(reply)}</span>
693
+ <button type="button" class="icon-btn reply-cancel" title=${t("chat.replyCancel")} aria-label=${t("chat.replyCancel")} onClick=${() => { store.clearReply(name); input.current?.focus(); }}><${Icon} name="close" size=${14} /></button>
694
+ </div>` : null}
658
695
  ${files.length ? html`<div class="pending-files">${files.map((f, i) => html`<${FileChip} key=${`${f.name}-${i}`} f=${f} i=${i} name=${name} />`)}</div>` : null}
659
696
  <div class="composer">
660
697
  <button id="attachBtn" type="button" class="icon-btn" title=${t("chat.attachTitle")} aria-label=${t("chat.attachTitle")} onClick=${() => fileIn.current && fileIn.current.click()}><${Icon} name="attach" /></button>
@@ -20,11 +20,12 @@ import { Empty } from "/assets/ui-states.js";
20
20
  import { Icon } from "/assets/ui-icons.js";
21
21
  import { toast } from "/assets/ui-toast.js";
22
22
  import { notifyStore, notifySupport, reasonText, refreshNotify, setNotify } from "/assets/app-needs.js";
23
+ import { openSignIn, signInOffered } from "./backend-signin.js";
23
24
 
24
25
  register("needs", {
25
26
  en: {
26
27
  title: "Needs you", empty: "Nothing needs you right now", emptyHint: "When an agent waits for you, it shows here — and in your chat app's General.", emptyHintWeb: "When an agent waits for you, it shows here.",
27
- open: "Open chat", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
28
+ open: "Open chat", signIn: "Sign in", acknowledge: "Acknowledge", acknowledging: "Acknowledging…", answering: "Answering…",
28
29
  ageNow: "just now", ageMin: "{0} min", ageHour: "{0} h", ageDay: "{0} d",
29
30
  notifyTitle: "Notify me on this device", notifyOn: "Notifications are on for this browser while a tab is open.",
30
31
  notifyOff: "Get a desktop notification when something new needs you while this tab is in the background.",
@@ -36,7 +37,7 @@ register("needs", {
36
37
  },
37
38
  "zh-TW": {
38
39
  title: "等你處理", empty: "目前沒有需要你處理的事", emptyHint: "agent 在等你時,會出現在這裡——也會出現在聊天 app 的 General。", emptyHintWeb: "agent 在等你時,會出現在這裡。",
39
- open: "開啟聊天", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
40
+ open: "開啟聊天", signIn: "登入", acknowledge: "確認", acknowledging: "確認中…", answering: "回覆中…",
40
41
  ageNow: "剛剛", ageMin: "{0} 分鐘", ageHour: "{0} 小時", ageDay: "{0} 天",
41
42
  notifyTitle: "在這台裝置通知我", notifyOn: "這個瀏覽器的通知已開啟(需要開著分頁)。",
42
43
  notifyOff: "分頁在背景時,有新的事等你處理就跳出桌面通知。",
@@ -185,6 +186,7 @@ function NeedsItem({ item, nowMs, owner }) {
185
186
  disabled=${promptBusy} onClick=${() => answer(a.id)}>${(p ? p.busy : fallback === "busy") ? tn("answering") : a.label}</button>`) : null}
186
187
  ${item.type === "delivery" ? html`<button type="button" class="btn btn-sm" disabled=${!!ack} onClick=${acknowledge}>
187
188
  <${Icon} name="check" size=${14} />${ack === "busy" ? tn("acknowledging") : tn("acknowledge")}</button>` : null}
189
+ ${item.reason === "auth_paused" && item.signIn && signInOffered() ? html`<button type="button" class="btn btn-sm btn-primary" onClick=${() => openSignIn(item.signIn)}>${tn("signIn")}</button>` : null}
188
190
  <a class="btn btn-sm btn-ghost" href=${chatPath(item.instance)}>${tn("open")}</a>
189
191
  </div>
190
192
  </div>
@@ -45,6 +45,7 @@ import "./chat-strings.js";
45
45
  import { AppearanceSection } from "./settings-appearance.js";
46
46
  import { GuestsCard } from "./settings-guests.js";
47
47
  import { WebAuthorityCard } from "./settings-web-authority.js";
48
+ import { SignInList } from "./backend-signin.js";
48
49
 
49
50
  const tn = (k, ...v) => t(`settings.${k}`, ...v);
50
51
  /** What a refused change says to a person: the server's code in words (the code itself only for one not listed). */
@@ -475,8 +476,10 @@ function Agents({ ctx, search, openDialog }) {
475
476
  };
476
477
  const head = html`<div class="list-head"><span>${Object.keys(insts).length === 1 ? tn("countAgent") : tn("countAgents", Object.keys(insts).length)}</span>
477
478
  <button type="button" class="btn btn-primary" onClick=${() => openDialog({ kind: "create" })}><${Icon} name="plus" size=${16} />${tn("newAgent")}</button></div>`;
478
- if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />`;
479
- if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />`;
479
+ // #2233: after the agents, each login on this machine with Sign in (backend-signin.js).
480
+ const signIns = html`<${SignInList} />`;
481
+ if (!Object.keys(insts).length) return html`${head}<${Empty} icon="bot" title=${tn("noAgents")} />${signIns}`;
482
+ if (!groups.length) return html`${head}<${Empty} icon="search" title=${tn("noAgentMatch", search.trim())} />${signIns}`;
480
483
  return html`${head}<div class="s-list">${groups.map(({ group, names }) => {
481
484
  const folded = collapsed.has(group) && !search.trim(); // searching opens every group
482
485
  return html`<section key=${group} class="s-group">
@@ -508,7 +511,7 @@ function Agents({ ctx, search, openDialog }) {
508
511
  </div>`;
509
512
  })}
510
513
  </section>`;
511
- })}</div>`;
514
+ })}</div>${signIns}`;
512
515
  }
513
516
 
514
517
  function ConnectionRetry({ id, ctx }) {
@@ -20,7 +20,7 @@ import { OwnerCssKeepBar } from "./owner-css.js";
20
20
  import { AgentWidget, fleetBinds, sidebarWidget, sidebarWidgetStore } from "./agent-widget.js";
21
21
 
22
22
  /** Shell state shared with panels: the phone drawer, the collapsed sidebar, extensions a panel module adds. */
23
- export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null });
23
+ export const shellStore = createStore({ drawer: false, collapsed: readCollapsed(), keyboard: false, dialog: null, side: null, banner: null });
24
24
 
25
25
  /**
26
26
  * The mounted panel's own sidebar section, in place of the instance list (View's roster: its groups, filter and order,
@@ -617,6 +617,7 @@ export function Shell({ panels, onNewInstance, viewOnly = false }) {
617
617
  <main id="main" class="main" tabindex="-1" inert=${shell.drawer && narrow() ? true : undefined}>
618
618
  <${ConnectionLine} />
619
619
  ${viewOnly || guest ? null : html`<${OperationLine} route=${nav.route} />`}
620
+ ${viewOnly || guest || !shell.banner ? null : html`<${shell.banner} />`}
620
621
  <${Outlet} route=${nav.route} seq=${nav.seq} panels=${panels} />
621
622
  </main>
622
623
  ${guest ? null : html`<${UsageRail} route=${nav.route} />`}
@@ -32,7 +32,8 @@
32
32
  // #1218: steps_tick — an instance's transcript moved while a Log watches it (SSE only: a poll page asks the Log itself).
33
33
  // #1918: traffic — a message between instances moved; delegation — a task opened, or its report closed it (SSE only: the
34
34
  // org canvas reads /ui/traffic when it has no stream).
35
- const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic", "delegation"];
35
+ // #2233: backend_login — a dashboard sign-in started, moved or ended (no details: the page reads its own view).
36
+ const EVENTS = ["status", "message", "agent_reply_live", "delivery", "deliveries", "prompt", "prompts", "prompt_resolved", "activity", "needs", "reply_buttons", "steps_tick", "clock", "traffic", "delegation", "backend_login"];
36
37
  const MAX_HELD = 1000; // live events held during one catch-up read; more → read again afterwards
37
38
  const RETRY_DELAYS = [1000, 2000, 4000]; // after the first failed catch-up read; then it waits for Retry
38
39
  const POLL_LIMIT_MS = 10000; // a poll that has not answered by then counts as failed