@songsid/agend 2.3.0-beta.15 → 2.3.0-beta.17

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 (128) 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 +73 -4
  38. package/dist/locale.js.map +1 -1
  39. package/dist/login-controller.d.ts +45 -0
  40. package/dist/login-controller.js +101 -5
  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/tunnel/cloudflared.js +37 -2
  64. package/dist/tunnel/cloudflared.js.map +1 -1
  65. package/dist/tunnel/types.d.ts +3 -1
  66. package/dist/tunnel/types.js.map +1 -1
  67. package/dist/ui/backend-signin.js +297 -0
  68. package/dist/ui/chat-msg-actions.js +176 -0
  69. package/dist/ui/chat-store.js +33 -3
  70. package/dist/ui/chat-strings.js +8 -2
  71. package/dist/ui/chat-thread.js +44 -2
  72. package/dist/ui/panel-chat.js +37 -0
  73. package/dist/ui/panel-needs.js +4 -2
  74. package/dist/ui/panel-settings.js +6 -3
  75. package/dist/ui/shared/app-shell.js +2 -1
  76. package/dist/ui/shared/app-stream.js +2 -1
  77. package/dist/ui/shared/app.css +42 -0
  78. package/dist/ui/shared/app.js +2 -0
  79. package/dist/ui/shared/usage-dialog.js +26 -3
  80. package/dist/ui/shared/view-strings.js +10 -0
  81. package/dist/ui/web-terminal/terminal.html +1 -1
  82. package/dist/update-launch.d.ts +9 -0
  83. package/dist/update-launch.js +38 -12
  84. package/dist/update-launch.js.map +1 -1
  85. package/dist/usage/format-rich.js +6 -3
  86. package/dist/usage/format-rich.js.map +1 -1
  87. package/dist/usage/i18n-keys.d.ts +1 -1
  88. package/dist/usage/i18n-keys.js +2 -1
  89. package/dist/usage/i18n-keys.js.map +1 -1
  90. package/dist/usage/i18n.d.ts +13 -1
  91. package/dist/usage/i18n.js +29 -0
  92. package/dist/usage/i18n.js.map +1 -1
  93. package/dist/usage/providers.d.ts +46 -1
  94. package/dist/usage/providers.js +68 -15
  95. package/dist/usage/providers.js.map +1 -1
  96. package/dist/usage/usage-api.d.ts +2 -0
  97. package/dist/usage/usage-api.js +180 -55
  98. package/dist/usage/usage-api.js.map +1 -1
  99. package/dist/user-systemd.d.ts +24 -0
  100. package/dist/user-systemd.js +75 -0
  101. package/dist/user-systemd.js.map +1 -0
  102. package/dist/web-api.d.ts +10 -1
  103. package/dist/web-api.js +134 -5
  104. package/dist/web-api.js.map +1 -1
  105. package/dist/web-backend-login.d.ts +130 -0
  106. package/dist/web-backend-login.js +225 -0
  107. package/dist/web-backend-login.js.map +1 -0
  108. package/dist/web-chat-history.d.ts +31 -0
  109. package/dist/web-chat-history.js +49 -1
  110. package/dist/web-chat-history.js.map +1 -1
  111. package/dist/web-commands.d.ts +5 -1
  112. package/dist/web-commands.js +1 -1
  113. package/dist/web-commands.js.map +1 -1
  114. package/dist/web-guest-access.d.ts +3 -2
  115. package/dist/web-guest-access.js +10 -6
  116. package/dist/web-guest-access.js.map +1 -1
  117. package/dist/web-terminal-http.js +15 -2
  118. package/dist/web-terminal-http.js.map +1 -1
  119. package/dist/windows-shortcuts.d.ts +78 -0
  120. package/dist/windows-shortcuts.js +203 -0
  121. package/dist/windows-shortcuts.js.map +1 -0
  122. package/launcher/agend-install.cjs +548 -0
  123. package/launcher/install-admission.cjs +4 -39
  124. package/launcher/postinstall.cjs +14 -12
  125. package/package.json +3 -2
  126. package/scripts/preinstall-guard.cjs +2 -5
  127. package/templates/windows-shortcuts.ps1 +181 -0
  128. package/launcher/old-updater-note.cjs +0 -48
@@ -0,0 +1,297 @@
1
+ // #2233: signing a backend in from the dashboard — the dialog, the line every admin page shows while a sign-in runs,
2
+ // and the list Settings → Agents shows. Behind the session gate (/ui/js/); a guest's page never loads it.
3
+ //
4
+ // What the page is told comes from GET /ui/backend-login, as this session may see it (web-backend-login.ts): the
5
+ // session that started a sign-in gets its terminal link, access code and device code; every other admin page only that
6
+ // one runs, on which device, and may cancel it. The stream's `backend_login` event carries nothing — it only says to
7
+ // read again. Starting is two steps, as in chat: check (the server keeps what it found) and start (with its id).
8
+ import { html, useEffect, useState } from "/assets/app-html.js";
9
+ import { t, register } from "/assets/app-i18n.js";
10
+ import { appStore, createStore, useStore } from "/assets/app-store.js";
11
+ import { closeDialog, shellStore, showDialog } from "/assets/app-shell.js";
12
+ import { Dialog } from "/assets/ui-dialog.js";
13
+ import { toast } from "/assets/ui-toast.js";
14
+
15
+ register("signin", {
16
+ en: {
17
+ title: "Sign in to {0}",
18
+ checking: "Checking whether {0} is signed in…",
19
+ confirmDevice: "AgEnD runs {0}'s own sign-in on this machine. You get a link and a one-time code here, to finish in your browser.",
20
+ confirmTerminal: "AgEnD runs {0}'s own sign-in on this machine and opens it as a terminal in your browser (only that sign-in, no shell). You get the link and a one-time access code here.",
21
+ stillSignedIn: "It is still signed in: signing in again replaces that login.",
22
+ logoutFirst: "It signs out first ({0}), then in.",
23
+ publicNote: "Not at this machine? A temporary public link opens the terminal from anywhere, for this sign-in only.",
24
+ go: "Sign in",
25
+ goPublic: "Use a temporary public link",
26
+ cancel: "Cancel",
27
+ close: "Close",
28
+ starting: "Starting…",
29
+ deviceStep: "Open this page and enter the code:",
30
+ linkStep: "Open this page to sign in; if it gives you a code, paste it into the sign-in terminal:",
31
+ terminalStep: "Open the sign-in terminal and enter this access code there. It works once, for {0} minutes.",
32
+ openTerminal: "Open the sign-in terminal",
33
+ accessCode: "Access code",
34
+ code: "Code",
35
+ copy: "Copy",
36
+ copied: "Copied",
37
+ publicLink: "Public link: anyone with the link and the code could use it — keep both to yourself.",
38
+ loopback: "This terminal listens on the machine's own address only. Open it on that machine, or set web_terminal.bind for your network.",
39
+ waitingHint: "Waiting for the sign-in to show its link…",
40
+ signedIn: "{0} is signed in.",
41
+ ended: "The sign-in ended.",
42
+ cancelSignIn: "Cancel sign-in",
43
+ cancelled: "Sign-in cancelled.",
44
+ running: "Signing in to {0} (started on {1}).",
45
+ open: "Open",
46
+ expired: "That took too long — start again.",
47
+ notHere: "Signing in is not available over the public link. Open the dashboard on the machine running AgEnD, or use /login in your chat.",
48
+ notAdmin: "Only a fleet admin signed in on this machine or its network can sign a backend in.",
49
+ notOffered: "A public link is not offered for this sign-in.",
50
+ failed: "That did not work.",
51
+ listTitle: "Sign-ins",
52
+ listHint: "Each backend's login on this machine, and each credential profile an agent uses.",
53
+ stUsable: "signed in",
54
+ stNotSignedIn: "not signed in",
55
+ stNotInstalled: "not installed",
56
+ stTooOld: "too old",
57
+ stUnknown: "not checked",
58
+ signIn: "Sign in",
59
+ signInAgain: "Sign in again",
60
+ onHost: "Sign in on the machine: {0}",
61
+ install: "Install on the machine: {0}",
62
+ update: "Update on the machine: {0}",
63
+ profile: "{0} · profile {1}",
64
+ },
65
+ "zh-TW": {
66
+ title: "登入 {0}",
67
+ checking: "正在確認 {0} 是否已登入…",
68
+ confirmDevice: "AgEnD 會在這台機器上執行 {0} 自己的登入。這裡會給你一個連結和一次性代碼,在瀏覽器完成登入。",
69
+ confirmTerminal: "AgEnD 會在這台機器上執行 {0} 自己的登入,並在瀏覽器以終端機開啟(只有這個登入,沒有 shell)。這裡會給你連結和一次性存取碼。",
70
+ stillSignedIn: "它目前仍是登入狀態:重新登入會取代原本的登入。",
71
+ logoutFirst: "會先登出({0}),再登入。",
72
+ publicNote: "不在這台機器前?臨時公開連結可以從任何地方開啟終端機,只用於這次登入。",
73
+ go: "登入",
74
+ goPublic: "使用臨時公開連結",
75
+ cancel: "取消",
76
+ close: "關閉",
77
+ starting: "啟動中…",
78
+ deviceStep: "開啟這個頁面並輸入代碼:",
79
+ linkStep: "開啟這個頁面登入;如果它給你一組代碼,請貼到登入終端機:",
80
+ terminalStep: "開啟登入終端機,在那裡輸入這個存取碼。只能用一次,{0} 分鐘內有效。",
81
+ openTerminal: "開啟登入終端機",
82
+ accessCode: "存取碼",
83
+ code: "代碼",
84
+ copy: "複製",
85
+ copied: "已複製",
86
+ publicLink: "公開連結:拿到連結和代碼的人都能使用——兩者都只留給自己。",
87
+ loopback: "這個終端機只在本機位址上接聽。請在那台機器上開啟,或為你的網路設定 web_terminal.bind。",
88
+ waitingHint: "等待登入顯示連結…",
89
+ signedIn: "{0} 已登入。",
90
+ ended: "登入已結束。",
91
+ cancelSignIn: "取消登入",
92
+ cancelled: "已取消登入。",
93
+ running: "正在登入 {0}(從 {1} 啟動)。",
94
+ open: "開啟",
95
+ expired: "等太久了——請重新開始。",
96
+ notHere: "公開連結上不能登入。請在執行 AgEnD 的機器上開啟儀表板,或在聊天裡用 /login。",
97
+ notAdmin: "只有在這台機器或它的網路上登入的 fleet 管理員,才能讓後端登入。",
98
+ notOffered: "這次登入不提供公開連結。",
99
+ failed: "沒有成功。",
100
+ listTitle: "登入",
101
+ listHint: "這台機器上每個 backend 的登入,以及代理使用的每個憑證設定檔。",
102
+ stUsable: "已登入",
103
+ stNotSignedIn: "未登入",
104
+ stNotInstalled: "未安裝",
105
+ stTooOld: "版本太舊",
106
+ stUnknown: "未檢查",
107
+ signIn: "登入",
108
+ signInAgain: "重新登入",
109
+ onHost: "在機器上登入:{0}",
110
+ install: "在機器上安裝:{0}",
111
+ update: "在機器上更新:{0}",
112
+ profile: "{0} · 設定檔 {1}",
113
+ },
114
+ });
115
+ const tn = (k, ...v) => t(`signin.${k}`, ...v);
116
+ const shownOf = (backend, profile) => (profile ? tn("profile", backend, profile) : backend);
117
+
118
+ /** What this page knows: the current sign-in as this session may see it, and Settings' list. */
119
+ export const signInStore = createStore({ loaded: false, login: null, targets: [] });
120
+
121
+ let reading = null, again = false;
122
+ /**
123
+ * Read GET /ui/backend-login (one read at a time). Asked again while one is on its way (an event that came after it was
124
+ * sent): one more read follows it, so the last change is never missed (#2262 review).
125
+ */
126
+ export function refreshSignIn() {
127
+ if (reading) { again = true; return reading; }
128
+ reading = fetch("/ui/backend-login", { cache: "no-store" })
129
+ .then((r) => (r.ok ? r.json() : null))
130
+ .then((b) => { if (b) signInStore.set({ loaded: true, login: b.login || null, targets: Array.isArray(b.targets) ? b.targets : [] }); return b; })
131
+ .catch(() => null)
132
+ .finally(() => { reading = null; if (again) { again = false; void refreshSignIn(); } });
133
+ return reading;
134
+ }
135
+
136
+ let installed = false;
137
+ /** Once per admin page: listen for `backend_login`, read once, and show the line while a sign-in runs. */
138
+ export function installSignIn() {
139
+ if (installed || !signInOffered()) return;
140
+ installed = true;
141
+ const on = appStore.get().streamOn;
142
+ if (typeof on === "function") on("backend_login", () => { void refreshSignIn(); });
143
+ void refreshSignIn();
144
+ shellStore.set({ banner: SignInLine });
145
+ }
146
+
147
+ /** Open the dialog for one login (a backend's default one, or a credential profile). */
148
+ export function openSignIn({ backend, profile }) {
149
+ showDialog(SignInDialog, { backend, ...(profile ? { profile } : {}) });
150
+ }
151
+
152
+ /** Open the dialog on the running sign-in (its starter's view, or the cancel-only one). */
153
+ function openRunning(login) { showDialog(SignInDialog, { backend: login.backend, ...(login.profile ? { profile: login.profile } : {}), resume: true }); }
154
+
155
+ async function post(path, body) {
156
+ try {
157
+ const res = await fetch(path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
158
+ let json = null; try { json = await res.json(); } catch { /* none */ }
159
+ return { ok: res.ok, status: res.status, body: json };
160
+ } catch { return { ok: false, status: 0, body: null }; }
161
+ }
162
+
163
+ const errorText = (code) => (code === "confirm_expired" ? tn("expired") : code === "tunnel_not_offered" ? tn("notOffered")
164
+ : code === "sign_in_not_over_public_link" ? tn("notHere") : code === "sign_in_needs_a_local_admin" ? tn("notAdmin") : tn("failed"));
165
+ /** The public link's page (#2233: Sign in is refused there — never offered). */
166
+ export const signInOffered = () => !(typeof document !== "undefined" && document.body && document.body.dataset.publicLink === "1");
167
+ const loopbackHost = (h) => h === "localhost" || h === "::1" || h === "[::1]" || /^127\./.test(h);
168
+
169
+ function CopyField({ label, value }) {
170
+ const [copied, setCopied] = useState(false);
171
+ const copy = async () => { try { await navigator.clipboard.writeText(value); setCopied(true); } catch { toast(value); } };
172
+ return html`<div class="signin-field"><span class="signin-label">${label}</span><code class="signin-value">${value}</code>
173
+ <button type="button" class="btn btn-sm" onClick=${copy}>${copied ? tn("copied") : tn("copy")}</button></div>`;
174
+ }
175
+
176
+ /** The starter's view of a running sign-in: the device link and code, or the terminal and its access code. */
177
+ function Running({ login }) {
178
+ const { hint, terminal, accessCode } = login;
179
+ const away = terminal && terminal.loopback && typeof location !== "undefined" && !loopbackHost(location.hostname);
180
+ return html`<div class="signin-run">
181
+ ${hint ? html`<p>${hint.code ? tn("deviceStep") : tn("linkStep")}</p>
182
+ ${hint.linkable ? html`<p><a data-trust="navigation" class="btn btn-primary" href=${hint.url} target="_blank" rel="noopener noreferrer">${tn("open")}</a> <span class="signin-url">${hint.url}</span></p>`
183
+ : html`<${CopyField} label="URL" value=${hint.url} />`}
184
+ ${hint.code ? html`<${CopyField} label=${tn("code")} value=${hint.code} />` : null}` : null}
185
+ ${terminal ? html`<p>${tn("terminalStep", terminal.ttlMinutes)}</p>
186
+ <p><a data-trust="navigation" class="btn btn-primary" href=${terminal.url} target="_blank" rel="noopener noreferrer">${tn("openTerminal")}</a></p>
187
+ ${accessCode ? html`<${CopyField} label=${tn("accessCode")} value=${accessCode} />` : null}
188
+ ${terminal.public ? html`<p class="signin-note">${tn("publicLink")}</p>` : null}
189
+ ${away ? html`<p class="signin-note">${tn("loopback")}</p>` : null}` : null}
190
+ ${!hint && !terminal ? html`<p class="signin-note">${tn("waitingHint")}</p>` : null}
191
+ </div>`;
192
+ }
193
+
194
+ export function SignInDialog({ backend, profile, resume = false, onClose }) {
195
+ const s = useStore(signInStore);
196
+ const [step, setStep] = useState(resume ? { kind: "login" } : { kind: "checking" });
197
+ const [busy, setBusy] = useState(false);
198
+ const shown = shownOf(backend, profile);
199
+ const close = onClose || closeDialog;
200
+ useEffect(() => {
201
+ if (resume) return undefined;
202
+ let gone = false;
203
+ post("/ui/backend-login/check", { backend, ...(profile ? { profile } : {}) }).then((r) => {
204
+ if (gone) return;
205
+ if (r.body && r.body.confirm) setStep({ kind: "confirm", confirm: r.body.confirm });
206
+ else setStep({ kind: "message", text: (r.body && (r.body.message || (r.body.error && errorText(r.body.error)))) || tn("failed") });
207
+ });
208
+ return () => { gone = true; };
209
+ }, []);
210
+ const login = s.login;
211
+ const live = step.kind === "login" && login && login.phase === "running";
212
+ // While a running sign-in is on screen, read it every 2 s too (a page without the stream still sees it move).
213
+ useEffect(() => {
214
+ if (!live) return undefined;
215
+ const h = setInterval(() => { void refreshSignIn(); }, 2000);
216
+ return () => clearInterval(h);
217
+ }, [live]);
218
+ const start = async (tunnel) => {
219
+ setBusy(true); setStep({ kind: "starting" });
220
+ const r = await post("/ui/backend-login/start", { confirm: step.confirm.id, ...(tunnel ? { tunnel: true } : {}) });
221
+ setBusy(false);
222
+ if (r.body && r.body.login) { signInStore.set({ login: r.body.login }); setStep({ kind: "login" }); return; }
223
+ setStep({ kind: "message", text: (r.body && (r.body.message || (r.body.error && errorText(r.body.error)))) || tn("failed") });
224
+ };
225
+ const cancelRun = async () => {
226
+ setBusy(true);
227
+ const r = await post("/ui/backend-login/cancel", {});
228
+ setBusy(false);
229
+ toast((r.body && r.body.message) || tn("cancelled"));
230
+ void refreshSignIn();
231
+ };
232
+ let body, actions;
233
+ if (step.kind === "checking" || step.kind === "starting") {
234
+ body = html`<p role="status">${step.kind === "checking" ? tn("checking", shown) : tn("starting")}</p>`;
235
+ actions = html`<button type="button" class="btn" disabled=${busy} onClick=${close}>${tn("cancel")}</button>`;
236
+ } else if (step.kind === "confirm") {
237
+ const c = step.confirm;
238
+ body = html`<p>${c.deviceAuth ? tn("confirmDevice", shown) : tn("confirmTerminal", shown)}</p>
239
+ ${c.tokenPresent ? html`<p>${tn("stillSignedIn")}</p>` : null}
240
+ ${c.logoutFirst ? html`<p>${tn("logoutFirst", c.logoutFirst)}</p>` : null}
241
+ ${c.publicLink ? html`<p class="signin-note">${tn("publicNote")}</p>` : null}`;
242
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("cancel")}</button>
243
+ ${c.publicLink ? html`<button type="button" class="btn" onClick=${() => start(true)}>${tn("goPublic")}</button>` : null}
244
+ <button type="button" class="btn btn-primary" onClick=${() => start(false)}>${tn("go")}</button>`;
245
+ } else if (step.kind === "message") {
246
+ body = html`<p class="signin-message">${step.text}</p>`;
247
+ actions = html`<button type="button" class="btn btn-primary" onClick=${close}>${tn("close")}</button>`;
248
+ } else if (!login) {
249
+ body = html`<p class="signin-note">${tn("ended")}</p>`;
250
+ actions = html`<button type="button" class="btn btn-primary" onClick=${close}>${tn("close")}</button>`;
251
+ } else if (!login.mine) {
252
+ body = html`<p>${login.phase === "running" ? tn("running", shownOf(login.backend, login.profile), login.startedOn) : tn("ended")}</p>`;
253
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("close")}</button>
254
+ ${login.phase === "running" ? html`<button type="button" class="btn btn-danger" disabled=${busy} onClick=${cancelRun}>${tn("cancelSignIn")}</button>` : null}`;
255
+ } else {
256
+ const lines = Array.isArray(login.lines) ? login.lines : [];
257
+ body = html`${login.phase === "running" ? html`<${Running} login=${login} />`
258
+ : html`<p role="status" class=${login.ok ? "signin-ok" : "signin-message"}>${login.ok ? tn("signedIn", shownOf(login.backend, login.profile)) : tn("ended")}</p>`}
259
+ ${lines.length ? html`<ul class="signin-lines">${lines.map((l, i) => html`<li key=${i}>${l}</li>`)}</ul>` : null}`;
260
+ actions = html`<button type="button" class="btn" onClick=${close}>${tn("close")}</button>
261
+ ${login.phase === "running" ? html`<button type="button" class="btn btn-danger" disabled=${busy} onClick=${cancelRun}>${tn("cancelSignIn")}</button>` : null}`;
262
+ }
263
+ return html`<${Dialog} title=${tn("title", shown)} onClose=${close} busy=${busy} actions=${actions}>${body}</${Dialog}>`;
264
+ }
265
+
266
+ /** The line every admin page shows while a sign-in runs (the shell's banner slot). */
267
+ export function SignInLine() {
268
+ const { login } = useStore(signInStore);
269
+ if (!login || login.phase !== "running") return null;
270
+ return html`<div data-trust="status" class="conn op-line signin-line" role="status">
271
+ <span>${tn("running", shownOf(login.backend, login.profile), login.startedOn)}</span>
272
+ <button type="button" class="btn btn-sm" onClick=${() => openRunning(login)}>${tn("open")}</button></div>`;
273
+ }
274
+
275
+ const STATE_KEY = { usable: "stUsable", not_signed_in: "stNotSignedIn", not_installed: "stNotInstalled", too_old: "stTooOld", unknown: "stUnknown" };
276
+
277
+ /** Settings → Agents: each login on this machine, with Sign in where the dashboard can do it. */
278
+ export function SignInList() {
279
+ const { targets, loaded, login } = useStore(signInStore);
280
+ useEffect(() => { void refreshSignIn(); }, []);
281
+ if (!loaded || !targets.length) return null;
282
+ const running = login && login.phase === "running";
283
+ return html`<section class="s-group signin-list" aria-label=${tn("listTitle")}>
284
+ <div class="list-head"><span>${tn("listTitle")}</span></div>
285
+ <p class="sub">${tn("listHint")}</p>
286
+ ${targets.map((x) => html`<div key=${`${x.backend}:${x.profile || ""}`} class="s-row">
287
+ <span class="s-name">${shownOf(x.backend, x.profile)}</span>
288
+ <span class="tag">${tn(STATE_KEY[x.state] || "stUnknown")}</span>
289
+ <span class="s-br" aria-hidden="true"></span>
290
+ ${x.install ? html`<span class="s-meta"><code>${tn("install", x.install)}</code></span>` : null}
291
+ ${x.update ? html`<span class="s-meta"><code>${tn("update", x.update)}</code></span>` : null}
292
+ ${x.hostCommand ? html`<span class="s-meta"><code>${tn("onHost", x.hostCommand)}</code></span>` : null}
293
+ <span class="s-actions">${x.signIn ? html`<button type="button" class=${x.state === "not_signed_in" ? "btn btn-sm btn-primary" : "btn btn-sm"} disabled=${!!running}
294
+ onClick=${() => openSignIn(x)}>${x.state === "usable" ? tn("signInAgain") : tn("signIn")}</button>` : null}</span>
295
+ </div>`)}
296
+ </section>`;
297
+ }
@@ -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: "停止目前的回覆",