@delt/claude-alarm 0.8.2 → 0.9.1

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.
@@ -187,6 +187,7 @@
187
187
  outline: none;
188
188
  font-family: inherit;
189
189
  }
190
+ .name-edit-input.invalid { border-color: var(--red); }
190
191
  .session-name {
191
192
  font-size: 14px;
192
193
  font-weight: 500;
@@ -400,6 +401,39 @@
400
401
  }
401
402
  .drag-overlay.active { display: flex; }
402
403
 
404
+ .mention-error {
405
+ display: none;
406
+ padding: 6px 20px 0;
407
+ color: var(--red);
408
+ font-size: 12px;
409
+ }
410
+ .mention-error.show { display: block; }
411
+ .message-input-area { position: relative; }
412
+ .mention-popup {
413
+ display: none;
414
+ position: absolute;
415
+ bottom: calc(100% + 4px);
416
+ left: 20px;
417
+ min-width: 220px;
418
+ max-height: 200px;
419
+ overflow-y: auto;
420
+ background: var(--surface);
421
+ border: 1px solid var(--border);
422
+ border-radius: 6px;
423
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
424
+ z-index: 20;
425
+ }
426
+ .mention-popup.open { display: block; }
427
+ .mention-item {
428
+ display: flex;
429
+ justify-content: space-between;
430
+ gap: 12px;
431
+ padding: 6px 10px;
432
+ font-size: 13px;
433
+ cursor: pointer;
434
+ }
435
+ .mention-item.active, .mention-item:hover { background: var(--border); }
436
+ .mention-folder { color: var(--text-dim); font-size: 11px; }
403
437
  .message-input-area {
404
438
  border-top: 1px solid var(--border);
405
439
  padding: 12px 20px;
@@ -837,7 +871,7 @@
837
871
  <div class="token-overlay hidden" id="tokenOverlay">
838
872
  <div class="token-form">
839
873
  <h2>Authentication Required</h2>
840
- <p>Enter the hub token to connect. Find it by running: <code>claude-alarm token</code></p>
874
+ <p>Enter the hub token to connect. Run <code>claude-alarm token</code>, or open the login link printed by <code>claude-alarm hub start</code>.</p>
841
875
  <input type="text" id="tokenInput" placeholder="Paste token here..." autocomplete="off">
842
876
  <button id="tokenSubmit">Connect</button>
843
877
  <div class="token-error" id="tokenError">Connection failed. Check your token.</div>
@@ -963,7 +997,9 @@
963
997
  <span class="preview-name" id="previewName"></span>
964
998
  <button class="preview-cancel" id="previewCancel">&times;</button>
965
999
  </div>
1000
+ <div class="mention-error" id="mentionError"></div>
966
1001
  <div class="message-input-area">
1002
+ <div class="mention-popup" id="mentionPopup"></div>
967
1003
  <button class="attach-btn" id="attachBtn" disabled title="Attach image">&#128206;</button>
968
1004
  <input type="file" id="fileInput" accept="image/*" style="display:none">
969
1005
  <textarea id="msgInput" placeholder="Send a message to session... (Shift+Enter for new line)" disabled rows="1"></textarea>
@@ -990,7 +1026,6 @@
990
1026
  selectedSession: null,
991
1027
  messages: {},
992
1028
  notifications: [],
993
- token: null,
994
1029
  pendingImage: null,
995
1030
  unread: {},
996
1031
  waitingReply: {},
@@ -1095,6 +1130,146 @@
1095
1130
  return state.sessionMeta.names[s.id] || s.displayName || s.name;
1096
1131
  }
1097
1132
 
1133
+ function normName(s) {
1134
+ return String(s).normalize('NFC').trim().toLowerCase();
1135
+ }
1136
+
1137
+ function folderName(s) {
1138
+ return s.displayName || s.name;
1139
+ }
1140
+
1141
+ function renameError(val, id) {
1142
+ if (/[\[\]@]/.test(val)) return 'Name cannot contain [, ] or @';
1143
+ const key = normName(val);
1144
+ const taken = Object.values(state.sessions).some(o => o.id !== id &&
1145
+ (normName(sessionDisplayName(o)) === key || normName(folderName(o)) === key));
1146
+ return taken ? 'Name already used by another session' : null;
1147
+ }
1148
+
1149
+ function sessionNames(s) {
1150
+ return [state.sessionMeta.names[s.id], folderName(s), s.peerName].filter(Boolean);
1151
+ }
1152
+
1153
+ // Built-in SendMessage only reaches sessions on the hub's own machine.
1154
+ function mentionTargets() {
1155
+ const selected = state.sessions[state.selectedSession];
1156
+ if (!selected || !selected.isLocal) return [];
1157
+ return Object.values(state.sessions).filter(s => s.id !== selected.id && s.isLocal && s.peerName);
1158
+ }
1159
+
1160
+ function resolveMention(name) {
1161
+ const key = normName(name);
1162
+ const matches = mentionTargets().filter(s => sessionNames(s).some(n => normName(n) === key));
1163
+ return matches.length === 1 ? matches[0] : null;
1164
+ }
1165
+
1166
+ function mentionLabel(s) {
1167
+ return [sessionDisplayName(s), folderName(s), s.peerName].find(n => resolveMention(n) === s) || s.peerName;
1168
+ }
1169
+
1170
+ // A token only counts as a mention when it names (or starts with) some
1171
+ // session, so pasted code like @Override or npm @types/x is left alone.
1172
+ function looksLikeSessionMention(name) {
1173
+ const key = normName(name);
1174
+ return Object.values(state.sessions).some(s => sessionNames(s).some(n => key.startsWith(normName(n))));
1175
+ }
1176
+
1177
+ function extractMentions(text) {
1178
+ const prose = text.replace(/```[\s\S]*?(```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ');
1179
+ const re = /(^|\s)@(?:\[([^\]\n]+)\]|([^\s\[\]]+))/g;
1180
+ const found = [];
1181
+ let m;
1182
+ while ((m = re.exec(prose))) {
1183
+ const bracketed = m[2] !== undefined;
1184
+ const name = bracketed ? m[2] : m[3].replace(/[.,!?;:)]+$/, '');
1185
+ found.push({ raw: m[0].trim(), name, bracketed });
1186
+ }
1187
+ return found;
1188
+ }
1189
+
1190
+ // The routing line is the contract with the channel instructions in
1191
+ // src/channel/server.ts (ROUTING) — change both together.
1192
+ function buildRouting(text) {
1193
+ const mentions = extractMentions(text);
1194
+ if (!mentions.length) return { ok: true, content: text };
1195
+ const unknown = [];
1196
+ const lines = [];
1197
+ const seen = new Set();
1198
+ for (const m of mentions) {
1199
+ const s = resolveMention(m.name);
1200
+ if (!s) {
1201
+ if (m.bracketed || looksLikeSessionMention(m.name)) unknown.push(m.raw);
1202
+ continue;
1203
+ }
1204
+ if (seen.has(s.id)) continue;
1205
+ seen.add(s.id);
1206
+ lines.push(`[claude-alarm] @${m.name} = SendMessage to "${s.peerName}"`);
1207
+ }
1208
+ if (unknown.length) return { ok: false, unknown };
1209
+ if (!lines.length) return { ok: true, content: text };
1210
+ return { ok: true, content: `${text}\n\n${lines.join('\n')}` };
1211
+ }
1212
+
1213
+ function showMentionError(msg) {
1214
+ const el = $('#mentionError');
1215
+ el.textContent = msg || '';
1216
+ el.classList.toggle('show', !!msg);
1217
+ }
1218
+
1219
+ let mentionState = { open: false, items: [], index: 0, start: -1 };
1220
+
1221
+ function currentMentionQuery(input) {
1222
+ const upto = input.value.slice(0, input.selectionStart);
1223
+ const m = upto.match(/(^|\s)@(\[[^\]\n]*|[^\s\[\]]*)$/);
1224
+ if (!m) return null;
1225
+ return { start: upto.length - m[2].length - 1, query: m[2].replace(/^\[/, '') };
1226
+ }
1227
+
1228
+ function closeMentionPopup() {
1229
+ mentionState = { open: false, items: [], index: 0, start: -1 };
1230
+ $('#mentionPopup').classList.remove('open');
1231
+ }
1232
+
1233
+ function renderMentionPopup() {
1234
+ $('#mentionPopup').innerHTML = mentionState.items.map((s, i) =>
1235
+ `<div class="mention-item${i === mentionState.index ? ' active' : ''}" data-idx="${i}">` +
1236
+ `<span>${esc(sessionDisplayName(s))}</span><span class="mention-folder">${esc(folderName(s))}</span></div>`
1237
+ ).join('');
1238
+ }
1239
+
1240
+ function updateMentionPopup() {
1241
+ const q = currentMentionQuery($('#msgInput'));
1242
+ if (!q) return closeMentionPopup();
1243
+ const key = normName(q.query);
1244
+ const items = mentionTargets().filter(s =>
1245
+ normName(sessionDisplayName(s)).includes(key) || normName(folderName(s)).includes(key));
1246
+ if (!items.length) return closeMentionPopup();
1247
+ const index = mentionState.open ? Math.min(mentionState.index, items.length - 1) : 0;
1248
+ mentionState = { open: true, items, index, start: q.start };
1249
+ renderMentionPopup();
1250
+ $('#mentionPopup').classList.add('open');
1251
+ }
1252
+
1253
+ function applyMention(s) {
1254
+ const input = $('#msgInput');
1255
+ const label = mentionLabel(s);
1256
+ const token = /\s/.test(label) ? `@[${label}] ` : `@${label} `;
1257
+ const before = input.value.slice(0, mentionState.start);
1258
+ const after = input.value.slice(input.selectionStart);
1259
+ input.value = before + token + after;
1260
+ const pos = before.length + token.length;
1261
+ input.setSelectionRange(pos, pos);
1262
+ closeMentionPopup();
1263
+ input.focus();
1264
+ }
1265
+
1266
+ $('#mentionPopup').addEventListener('mousedown', (e) => {
1267
+ const item = e.target.closest('.mention-item');
1268
+ if (!item) return;
1269
+ e.preventDefault();
1270
+ applyMention(mentionState.items[Number(item.dataset.idx)]);
1271
+ });
1272
+
1098
1273
  // --- Theme ---
1099
1274
  function initTheme() {
1100
1275
  const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
@@ -1115,25 +1290,28 @@
1115
1290
  initTheme();
1116
1291
 
1117
1292
  // --- Token handling ---
1118
- function getToken() {
1119
- const params = new URLSearchParams(location.search);
1120
- const urlToken = params.get('token');
1121
- if (urlToken) {
1122
- sessionStorage.setItem('claude-alarm-token', urlToken);
1123
- return urlToken;
1293
+ async function checkAuth() {
1294
+ try {
1295
+ return (await fetch('/api/status')).status !== 401;
1296
+ } catch {
1297
+ return true;
1124
1298
  }
1125
- return sessionStorage.getItem('claude-alarm-token');
1126
1299
  }
1127
1300
 
1128
1301
  function showTokenForm() { $('#tokenOverlay').classList.remove('hidden'); }
1129
1302
  function hideTokenForm() { $('#tokenOverlay').classList.add('hidden'); }
1130
1303
 
1131
- $('#tokenSubmit').addEventListener('click', () => {
1304
+ $('#tokenSubmit').addEventListener('click', async () => {
1132
1305
  const token = $('#tokenInput').value.trim();
1133
1306
  if (!token) return;
1134
- state.token = token;
1135
- sessionStorage.setItem('claude-alarm-token', token);
1307
+ const res = await fetch('/api/login', {
1308
+ method: 'POST',
1309
+ headers: { 'Content-Type': 'application/json' },
1310
+ body: JSON.stringify({ token }),
1311
+ }).catch(() => null);
1312
+ if (!res || !res.ok) { $('#tokenError').style.display = 'block'; return; }
1136
1313
  $('#tokenError').style.display = 'none';
1314
+ $('#tokenInput').value = '';
1137
1315
  hideTokenForm();
1138
1316
  connect();
1139
1317
  });
@@ -1144,25 +1322,23 @@
1144
1322
  // --- WebSocket ---
1145
1323
  function connect() {
1146
1324
  const proto = location.protocol === 'https:' ? 'wss' : 'ws';
1147
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1148
- const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard${tokenQuery}`);
1325
+ const ws = new WebSocket(`${proto}://${location.host}/ws/dashboard`);
1326
+ let opened = false;
1149
1327
 
1150
1328
  ws.onopen = () => {
1329
+ opened = true;
1151
1330
  state.ws = ws;
1152
1331
  hideTokenForm();
1153
1332
  $('#connDot').classList.add('connected');
1154
1333
  $('#connLabel').textContent = 'Connected';
1155
1334
  };
1156
- ws.onclose = () => {
1335
+ ws.onclose = async () => {
1157
1336
  state.ws = null;
1158
1337
  $('#connDot').classList.remove('connected');
1159
1338
  $('#connLabel').textContent = 'Disconnected';
1339
+ if (!opened && !(await checkAuth())) { showTokenForm(); return; }
1160
1340
  setTimeout(connect, 3000);
1161
1341
  };
1162
- ws.onerror = () => {
1163
- if (!state.token) { showTokenForm(); } else { $('#tokenError').style.display = 'block'; showTokenForm(); }
1164
- ws.close();
1165
- };
1166
1342
  ws.onmessage = (e) => { try { handleMessage(JSON.parse(e.data)); } catch {} };
1167
1343
  }
1168
1344
 
@@ -1307,12 +1483,18 @@
1307
1483
  input.select();
1308
1484
 
1309
1485
  let done = false;
1310
- const commit = (save) => {
1486
+ const commit = (save, fromBlur) => {
1311
1487
  if (done) return;
1488
+ const val = input.value.trim();
1489
+ const baseName = s.displayName || s.name;
1490
+ const err = save && val && val !== baseName ? renameError(val, id) : null;
1491
+ if (err && !fromBlur) {
1492
+ input.classList.add('invalid');
1493
+ input.title = err;
1494
+ return;
1495
+ }
1312
1496
  done = true;
1313
- if (save) {
1314
- const val = input.value.trim();
1315
- const baseName = s.displayName || s.name;
1497
+ if (save && !err) {
1316
1498
  if (!val || val === baseName) delete state.sessionMeta.names[id];
1317
1499
  else state.sessionMeta.names[id] = val;
1318
1500
  saveSessionMeta();
@@ -1321,10 +1503,11 @@
1321
1503
  };
1322
1504
  input.addEventListener('keydown', (e) => {
1323
1505
  e.stopPropagation();
1324
- if (e.key === 'Enter') { e.preventDefault(); commit(true); }
1325
- else if (e.key === 'Escape') { e.preventDefault(); commit(false); }
1506
+ if (e.key === 'Enter') { e.preventDefault(); commit(true, false); }
1507
+ else if (e.key === 'Escape') { e.preventDefault(); commit(false, false); }
1326
1508
  });
1327
- input.addEventListener('blur', () => commit(true));
1509
+ input.addEventListener('input', () => { input.classList.remove('invalid'); input.title = ''; });
1510
+ input.addEventListener('blur', () => commit(true, true));
1328
1511
  input.addEventListener('click', (e) => e.stopPropagation());
1329
1512
  }
1330
1513
 
@@ -1577,6 +1760,13 @@
1577
1760
  if (!content && !hasImage) return;
1578
1761
  if (!state.selectedSession || !state.ws) return;
1579
1762
 
1763
+ const routing = buildRouting(content);
1764
+ if (!routing.ok) {
1765
+ showMentionError(`Unknown session: ${routing.unknown.join(', ')} — pick from the @ list, or put a space right after the name.`);
1766
+ return;
1767
+ }
1768
+ showMentionError(null);
1769
+
1580
1770
  if (hasImage) {
1581
1771
  // Send image + text together
1582
1772
  state.ws.send(JSON.stringify({
@@ -1585,11 +1775,11 @@
1585
1775
  imageData: state.pendingImage.data,
1586
1776
  mimeType: state.pendingImage.mimeType,
1587
1777
  originalName: state.pendingImage.name,
1588
- content: content || '',
1778
+ content: routing.content || '',
1589
1779
  }));
1590
1780
  } else if (content) {
1591
1781
  // Text only
1592
- state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content }));
1782
+ state.ws.send(JSON.stringify({ type: 'message_to_session', sessionId: state.selectedSession, content: routing.content }));
1593
1783
  }
1594
1784
 
1595
1785
  // Add to local messages
@@ -1604,6 +1794,7 @@
1604
1794
  saveMessages();
1605
1795
 
1606
1796
  input.value = '';
1797
+ closeMentionPopup();
1607
1798
  input.style.height = 'auto';
1608
1799
  clearPendingImage();
1609
1800
  state.waitingReply[state.selectedSession] = true;
@@ -1612,11 +1803,24 @@
1612
1803
 
1613
1804
  $('#sendBtn').addEventListener('click', sendMessage);
1614
1805
  $('#msgInput').addEventListener('keydown', (e) => {
1806
+ if (mentionState.open) {
1807
+ // Enter while an IME (e.g. Korean) is composing only commits the syllable.
1808
+ if (e.isComposing) return;
1809
+ const n = mentionState.items.length;
1810
+ if (e.key === 'ArrowDown') { e.preventDefault(); mentionState.index = (mentionState.index + 1) % n; renderMentionPopup(); return; }
1811
+ if (e.key === 'ArrowUp') { e.preventDefault(); mentionState.index = (mentionState.index - 1 + n) % n; renderMentionPopup(); return; }
1812
+ if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); applyMention(mentionState.items[mentionState.index]); return; }
1813
+ if (e.key === 'Escape') { e.preventDefault(); closeMentionPopup(); return; }
1814
+ }
1615
1815
  if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); sendMessage(); }
1616
1816
  });
1817
+ $('#msgInput').addEventListener('blur', closeMentionPopup);
1818
+ $('#msgInput').addEventListener('click', updateMentionPopup);
1617
1819
  $('#msgInput').addEventListener('input', function() {
1820
+ showMentionError(null);
1618
1821
  this.style.height = 'auto';
1619
1822
  this.style.height = Math.min(this.scrollHeight, 120) + 'px';
1823
+ updateMentionPopup();
1620
1824
  });
1621
1825
 
1622
1826
  function relativeTime(ts) {
@@ -1924,16 +2128,14 @@
1924
2128
  $('#settingsToggle').addEventListener('click', async () => {
1925
2129
  // Load webhook data
1926
2130
  try {
1927
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1928
- const res = await fetch(`/api/webhooks${tokenQuery}`);
2131
+ const res = await fetch(`/api/webhooks`);
1929
2132
  const data = await res.json();
1930
2133
  webhookData = data.webhooks || [];
1931
2134
  } catch { webhookData = []; }
1932
2135
  renderWebhooks();
1933
2136
  // Load telegram data
1934
2137
  try {
1935
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1936
- const res = await fetch(`/api/telegram${tokenQuery}`);
2138
+ const res = await fetch(`/api/telegram`);
1937
2139
  const data = await res.json();
1938
2140
  const tg = data.telegram || {};
1939
2141
  if (!$('#tgBotToken').value || $('#tgBotToken').value.includes('...')) {
@@ -1968,8 +2170,7 @@
1968
2170
  $('#webhookSave').addEventListener('click', async () => {
1969
2171
  const valid = webhookData.filter(w => w.url.trim());
1970
2172
  try {
1971
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
1972
- await fetch(`/api/webhooks${tokenQuery}`, {
2173
+ await fetch(`/api/webhooks`, {
1973
2174
  method: 'POST',
1974
2175
  headers: { 'Content-Type': 'application/json' },
1975
2176
  body: JSON.stringify({ webhooks: valid }),
@@ -2044,8 +2245,7 @@
2044
2245
  $('#tgDetectStatus').textContent = 'Detecting...';
2045
2246
  $('#tgChatList').innerHTML = '';
2046
2247
  try {
2047
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2048
- const res = await fetch(`/api/telegram/detect${tokenQuery}`, {
2248
+ const res = await fetch(`/api/telegram/detect`, {
2049
2249
  method: 'POST',
2050
2250
  headers: { 'Content-Type': 'application/json' },
2051
2251
  body: JSON.stringify({ botToken }),
@@ -2082,8 +2282,7 @@
2082
2282
  const chatId = $('#tgChatId').value.trim();
2083
2283
  if (!botToken || !chatId) { showTgStatus('Bot Token and Chat ID are required.', false); return; }
2084
2284
  try {
2085
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2086
- const res = await fetch(`/api/telegram/test${tokenQuery}`, {
2285
+ const res = await fetch(`/api/telegram/test`, {
2087
2286
  method: 'POST',
2088
2287
  headers: { 'Content-Type': 'application/json' },
2089
2288
  body: JSON.stringify({ botToken, chatId }),
@@ -2102,8 +2301,7 @@
2102
2301
  if (!botToken || !chatId) { showTgStatus('Bot Token and Chat ID are required.', false); return; }
2103
2302
  if (botToken.includes('...')) { showTgStatus('Bot Token is masked. Please enter the full token.', false); return; }
2104
2303
  try {
2105
- const tokenQuery = state.token ? `?token=${encodeURIComponent(state.token)}` : '';
2106
- await fetch(`/api/telegram${tokenQuery}`, {
2304
+ await fetch(`/api/telegram`, {
2107
2305
  method: 'POST',
2108
2306
  headers: { 'Content-Type': 'application/json' },
2109
2307
  body: JSON.stringify({ telegram: { botToken, chatId, enabled } }),
@@ -2131,8 +2329,7 @@
2131
2329
  document.querySelector('.sessions-panel').classList.add('mobile-active');
2132
2330
  }
2133
2331
 
2134
- state.token = getToken();
2135
- connect();
2332
+ checkAuth().then((ok) => (ok ? connect() : showTokenForm()));
2136
2333
  })();
2137
2334
  </script>
2138
2335
  </body>