@delt/claude-alarm 0.8.2 → 0.9.0

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;
@@ -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>
@@ -1095,6 +1131,146 @@
1095
1131
  return state.sessionMeta.names[s.id] || s.displayName || s.name;
1096
1132
  }
1097
1133
 
1134
+ function normName(s) {
1135
+ return String(s).normalize('NFC').trim().toLowerCase();
1136
+ }
1137
+
1138
+ function folderName(s) {
1139
+ return s.displayName || s.name;
1140
+ }
1141
+
1142
+ function renameError(val, id) {
1143
+ if (/[\[\]@]/.test(val)) return 'Name cannot contain [, ] or @';
1144
+ const key = normName(val);
1145
+ const taken = Object.values(state.sessions).some(o => o.id !== id &&
1146
+ (normName(sessionDisplayName(o)) === key || normName(folderName(o)) === key));
1147
+ return taken ? 'Name already used by another session' : null;
1148
+ }
1149
+
1150
+ function sessionNames(s) {
1151
+ return [state.sessionMeta.names[s.id], folderName(s), s.peerName].filter(Boolean);
1152
+ }
1153
+
1154
+ // Built-in SendMessage only reaches sessions on the hub's own machine.
1155
+ function mentionTargets() {
1156
+ const selected = state.sessions[state.selectedSession];
1157
+ if (!selected || !selected.isLocal) return [];
1158
+ return Object.values(state.sessions).filter(s => s.id !== selected.id && s.isLocal && s.peerName);
1159
+ }
1160
+
1161
+ function resolveMention(name) {
1162
+ const key = normName(name);
1163
+ const matches = mentionTargets().filter(s => sessionNames(s).some(n => normName(n) === key));
1164
+ return matches.length === 1 ? matches[0] : null;
1165
+ }
1166
+
1167
+ function mentionLabel(s) {
1168
+ return [sessionDisplayName(s), folderName(s), s.peerName].find(n => resolveMention(n) === s) || s.peerName;
1169
+ }
1170
+
1171
+ // A token only counts as a mention when it names (or starts with) some
1172
+ // session, so pasted code like @Override or npm @types/x is left alone.
1173
+ function looksLikeSessionMention(name) {
1174
+ const key = normName(name);
1175
+ return Object.values(state.sessions).some(s => sessionNames(s).some(n => key.startsWith(normName(n))));
1176
+ }
1177
+
1178
+ function extractMentions(text) {
1179
+ const prose = text.replace(/```[\s\S]*?(```|$)/g, ' ').replace(/`[^`\n]*`/g, ' ');
1180
+ const re = /(^|\s)@(?:\[([^\]\n]+)\]|([^\s\[\]]+))/g;
1181
+ const found = [];
1182
+ let m;
1183
+ while ((m = re.exec(prose))) {
1184
+ const bracketed = m[2] !== undefined;
1185
+ const name = bracketed ? m[2] : m[3].replace(/[.,!?;:)]+$/, '');
1186
+ found.push({ raw: m[0].trim(), name, bracketed });
1187
+ }
1188
+ return found;
1189
+ }
1190
+
1191
+ // The routing line is the contract with the channel instructions in
1192
+ // src/channel/server.ts (ROUTING) — change both together.
1193
+ function buildRouting(text) {
1194
+ const mentions = extractMentions(text);
1195
+ if (!mentions.length) return { ok: true, content: text };
1196
+ const unknown = [];
1197
+ const lines = [];
1198
+ const seen = new Set();
1199
+ for (const m of mentions) {
1200
+ const s = resolveMention(m.name);
1201
+ if (!s) {
1202
+ if (m.bracketed || looksLikeSessionMention(m.name)) unknown.push(m.raw);
1203
+ continue;
1204
+ }
1205
+ if (seen.has(s.id)) continue;
1206
+ seen.add(s.id);
1207
+ lines.push(`[claude-alarm] @${m.name} = SendMessage to "${s.peerName}"`);
1208
+ }
1209
+ if (unknown.length) return { ok: false, unknown };
1210
+ if (!lines.length) return { ok: true, content: text };
1211
+ return { ok: true, content: `${text}\n\n${lines.join('\n')}` };
1212
+ }
1213
+
1214
+ function showMentionError(msg) {
1215
+ const el = $('#mentionError');
1216
+ el.textContent = msg || '';
1217
+ el.classList.toggle('show', !!msg);
1218
+ }
1219
+
1220
+ let mentionState = { open: false, items: [], index: 0, start: -1 };
1221
+
1222
+ function currentMentionQuery(input) {
1223
+ const upto = input.value.slice(0, input.selectionStart);
1224
+ const m = upto.match(/(^|\s)@(\[[^\]\n]*|[^\s\[\]]*)$/);
1225
+ if (!m) return null;
1226
+ return { start: upto.length - m[2].length - 1, query: m[2].replace(/^\[/, '') };
1227
+ }
1228
+
1229
+ function closeMentionPopup() {
1230
+ mentionState = { open: false, items: [], index: 0, start: -1 };
1231
+ $('#mentionPopup').classList.remove('open');
1232
+ }
1233
+
1234
+ function renderMentionPopup() {
1235
+ $('#mentionPopup').innerHTML = mentionState.items.map((s, i) =>
1236
+ `<div class="mention-item${i === mentionState.index ? ' active' : ''}" data-idx="${i}">` +
1237
+ `<span>${esc(sessionDisplayName(s))}</span><span class="mention-folder">${esc(folderName(s))}</span></div>`
1238
+ ).join('');
1239
+ }
1240
+
1241
+ function updateMentionPopup() {
1242
+ const q = currentMentionQuery($('#msgInput'));
1243
+ if (!q) return closeMentionPopup();
1244
+ const key = normName(q.query);
1245
+ const items = mentionTargets().filter(s =>
1246
+ normName(sessionDisplayName(s)).includes(key) || normName(folderName(s)).includes(key));
1247
+ if (!items.length) return closeMentionPopup();
1248
+ const index = mentionState.open ? Math.min(mentionState.index, items.length - 1) : 0;
1249
+ mentionState = { open: true, items, index, start: q.start };
1250
+ renderMentionPopup();
1251
+ $('#mentionPopup').classList.add('open');
1252
+ }
1253
+
1254
+ function applyMention(s) {
1255
+ const input = $('#msgInput');
1256
+ const label = mentionLabel(s);
1257
+ const token = /\s/.test(label) ? `@[${label}] ` : `@${label} `;
1258
+ const before = input.value.slice(0, mentionState.start);
1259
+ const after = input.value.slice(input.selectionStart);
1260
+ input.value = before + token + after;
1261
+ const pos = before.length + token.length;
1262
+ input.setSelectionRange(pos, pos);
1263
+ closeMentionPopup();
1264
+ input.focus();
1265
+ }
1266
+
1267
+ $('#mentionPopup').addEventListener('mousedown', (e) => {
1268
+ const item = e.target.closest('.mention-item');
1269
+ if (!item) return;
1270
+ e.preventDefault();
1271
+ applyMention(mentionState.items[Number(item.dataset.idx)]);
1272
+ });
1273
+
1098
1274
  // --- Theme ---
1099
1275
  function initTheme() {
1100
1276
  const saved = localStorage.getItem('claude-alarm-theme') || 'dark';
@@ -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) {