Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit829a046unknown_key

feat(ux): @mention autocomplete on all comment textareas

feat(ux): @mention autocomplete on all comment textareas

Adds a floating suggestion dropdown that activates when a user types `@`
followed by a non-whitespace prefix in any textarea on the site.

- GET /api/users/suggest?q=<prefix> — new endpoint using ILIKE prefix
  match on usernames, returning up to 8 results (no auth required since
  usernames are public).
- src/lib/mention-autocomplete.ts — self-contained IIFE that attaches
  to all textareas via event delegation. 120ms debounce, arrow-key
  navigation, Enter/Tab to accept, Escape to dismiss, click-outside to
  close. Injects its own scoped CSS once on first use.
- Injected into PR detail and issue detail pages via a <script> tag.

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: 920812b
4 files changed+1241829a04685416a9bf61f7ac0d1aa8e3ff562c82bb
4 changed files+124−1
Addedsrc/lib/mention-autocomplete.ts+101−0View fileUnifiedSplit
1/**
2 * @mention autocomplete for comment textareas.
3 *
4 * Injects a floating dropdown when the user types `@` followed by at least
5 * one character. Fetches `/api/users/suggest?q=<prefix>` and renders up to
6 * 8 matching usernames. Arrow-key navigation + Enter/Tab to select.
7 *
8 * Usage: call `mentionAutocompleteScript()` and inject the return value
9 * into a `<script dangerouslySetInnerHTML={{ __html: ... }} />` tag inside
10 * any page that has comment textareas. The script attaches itself to ALL
11 * textareas on the page via event delegation.
12 */
13
14export function mentionAutocompleteScript(): string {
15 return `(function(){
16 var _mc_popup=null,_mc_ta=null,_mc_idx=0,_mc_items=[];
17 var _mc_css=[
18 '.mc-popup{position:fixed;z-index:9999;background:var(--bg-elevated,#1c2128);border:1px solid var(--border,#30363d);border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.40);overflow:hidden;min-width:180px;max-width:280px;}',
19 '.mc-item{display:flex;align-items:center;gap:8px;padding:8px 12px;font-size:13px;cursor:pointer;color:var(--text,#e6edf3);}',
20 '.mc-item:hover,.mc-item.is-sel{background:var(--bg-hover,#21262d);}',
21 '.mc-item-at{color:var(--text-muted,#8b949e);font-size:12px;}',
22 '.mc-item-name{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:500;}',
23 ].join('');
24 function injectStyle(){if(!document.getElementById('mc-style')){var s=document.createElement('style');s.id='mc-style';s.textContent=_mc_css;document.head.appendChild(s);}}
25 function getCaretCoords(ta){
26 var mirror=document.createElement('div');
27 var cs=getComputedStyle(ta);
28 ['fontFamily','fontSize','fontWeight','letterSpacing','lineHeight','padding','border','boxSizing','whiteSpace','wordWrap','overflowWrap'].forEach(function(p){mirror.style[p]=cs[p];});
29 mirror.style.position='absolute';mirror.style.top='-9999px';mirror.style.left='-9999px';mirror.style.width=ta.offsetWidth+'px';mirror.style.height='auto';
30 var text=ta.value.slice(0,ta.selectionStart);
31 mirror.textContent=text;
32 var span=document.createElement('span');span.textContent='@';mirror.appendChild(span);
33 document.body.appendChild(mirror);
34 var r=ta.getBoundingClientRect();
35 var rect=span.getBoundingClientRect();
36 document.body.removeChild(mirror);
37 return{top:rect.top,left:rect.left};
38 }
39 function showPopup(ta,items,atPos){
40 injectStyle();hidePopup();
41 if(!items.length)return;
42 _mc_ta=ta;_mc_items=items;_mc_idx=0;
43 _mc_popup=document.createElement('div');_mc_popup.className='mc-popup';
44 items.forEach(function(u,i){
45 var d=document.createElement('div');d.className='mc-item'+(i===0?' is-sel':'');
46 d.innerHTML='<span class="mc-item-at">@</span><span class="mc-item-name">'+escHtml(u.username)+'</span>';
47 d.addEventListener('mousedown',function(e){e.preventDefault();insertMention(u.username,atPos);});
48 _mc_popup.appendChild(d);
49 });
50 var coords=getCaretCoords(ta);
51 _mc_popup.style.top=(coords.top+20)+'px';
52 _mc_popup.style.left=coords.left+'px';
53 document.body.appendChild(_mc_popup);
54 }
55 function hidePopup(){if(_mc_popup){_mc_popup.remove();_mc_popup=null;}_mc_ta=null;_mc_items=[];_mc_idx=0;}
56 function setSelection(i){_mc_idx=i;var els=_mc_popup?_mc_popup.querySelectorAll('.mc-item'):[];els.forEach(function(el,j){el.classList.toggle('is-sel',j===i);});}
57 function insertMention(username,atPos){
58 if(!_mc_ta)return;
59 var val=_mc_ta.value;var before=val.slice(0,atPos);var after=val.slice(_mc_ta.selectionStart);
60 var newVal=before+'@'+username+' '+after;
61 _mc_ta.value=newVal;
62 var pos=atPos+username.length+2;
63 _mc_ta.setSelectionRange(pos,pos);
64 hidePopup();
65 _mc_ta.dispatchEvent(new Event('input',{bubbles:true}));
66 }
67 function escHtml(s){return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
68 var _mc_timer=null;
69 document.addEventListener('input',function(e){
70 var ta=e.target;
71 if(ta.tagName!=='TEXTAREA')return;
72 clearTimeout(_mc_timer);
73 var val=ta.value;var pos=ta.selectionStart;
74 var lastAt=val.lastIndexOf('@',pos-1);
75 if(lastAt===-1){hidePopup();return;}
76 var afterAt=val.slice(lastAt+1,pos);
77 if(/\\s/.test(afterAt)||afterAt.length===0){hidePopup();return;}
78 if(afterAt.length>20){hidePopup();return;}
79 var atPos=lastAt;
80 _mc_timer=setTimeout(function(){
81 fetch('/api/users/suggest?q='+encodeURIComponent(afterAt)).then(function(r){return r.json();}).then(function(data){
82 if(!ta.isConnected){hidePopup();return;}
83 showPopup(ta,data.users||[],atPos);
84 }).catch(function(){hidePopup();});
85 },120);
86 });
87 document.addEventListener('keydown',function(e){
88 if(!_mc_popup)return;
89 if(e.key==='ArrowDown'){e.preventDefault();setSelection(Math.min(_mc_idx+1,_mc_items.length-1));}
90 else if(e.key==='ArrowUp'){e.preventDefault();setSelection(Math.max(_mc_idx-1,0));}
91 else if(e.key==='Enter'||e.key==='Tab'){
92 if(_mc_popup){e.preventDefault();insertMention(_mc_items[_mc_idx].username,_mc_popup._atPos||0);
93 // re-read atPos from last known
94 var u=_mc_items[_mc_idx];if(u&&_mc_ta){var val=_mc_ta.value;var p=_mc_ta.selectionStart;var lastAt=val.lastIndexOf('@',p-1);insertMention(u.username,lastAt);}}
95 }
96 else if(e.key==='Escape'){hidePopup();}
97 });
98 document.addEventListener('click',function(e){if(_mc_popup&&!_mc_popup.contains(e.target))hidePopup();});
99 document.addEventListener('blur',function(e){if(e.target.tagName==='TEXTAREA')setTimeout(function(){if(_mc_popup&&!_mc_popup.matches(':hover'))hidePopup();},150);},true);
100})();`;
101}
Modifiedsrc/routes/api.ts+19−1View fileUnifiedSplit
33 */
44
55import { Hono } from "hono";
6import { eq, and, isNull } from "drizzle-orm";
6import { eq, and, isNull, ilike, sql } from "drizzle-orm";
77import { db } from "../db";
88import { users, repositories, organizations, orgMembers } from "../db/schema";
99import { initBareRepo, repoExists } from "../git/repository";
247247 }
248248});
249249
250// User mention autocomplete — returns up to 8 matching usernames for `@` suggestions.
251// Public endpoint (no auth required) since usernames are public.
252api.get("/users/suggest", async (c) => {
253 const q = String(c.req.query("q") || "").trim().replace(/^@/, "").slice(0, 39);
254 if (!q) return c.json({ users: [] });
255 try {
256 const rows = await db
257 .select({ username: users.username, displayName: users.displayName })
258 .from(users)
259 .where(ilike(users.username, `${q}%`))
260 .orderBy(sql`lower(${users.username})`)
261 .limit(8);
262 return c.json({ users: rows });
263 } catch {
264 return c.json({ users: [] });
265 }
266});
267
250268export default api;
Modifiedsrc/routes/issues.tsx+2−0View fileUnifiedSplit
2222import { loadIssueTemplate } from "../lib/templates";
2323import { renderMarkdown } from "../lib/markdown";
2424import { liveCommentBannerScript } from "../lib/sse-client";
25import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
2526import { triggerIssueTriage, ISSUE_TRIAGE_MARKER } from "../lib/issue-triage";
2627import { softAuth, requireAuth } from "../middleware/auth";
2728import type { AuthEnv } from "../middleware/auth";
12621263 }),
12631264 }}
12641265 />
1266 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
12651267 <div class="issue-detail">
12661268 {info && (
12671269 <div class="issues-info-banner">
Modifiedsrc/routes/pulls.tsx+2−0View fileUnifiedSplit
4040 stripSlashCmdMarker,
4141} from "../lib/pr-slash-commands";
4242import { liveCommentBannerScript } from "../lib/sse-client";
43import { mentionAutocompleteScript } from "../lib/mention-autocomplete";
4344import { softAuth, requireAuth } from "../middleware/auth";
4445import type { AuthEnv } from "../middleware/auth";
4546import { requireRepoAccess } from "../middleware/repo-access";
35193520 __html: LIVE_COEDIT_SCRIPT(pr.id),
35203521 }}
35213522 />
3523 <script dangerouslySetInnerHTML={{ __html: mentionAutocompleteScript() }} />
35223524
35233525 <nav class="prs-detail-tabs" aria-label="Pull request sections">
35243526 <a
35253527