feat: structured @-mentions (notify+clickable+hover), distinct links, Ctrl-rebalance sliders

- Mentions now insert a span carrying the user id (handles names with spaces):
  the sanitizer permits <span class="mention" data-user-card="ULID">, comment
  and chat notifications resolve by id and only fire for users with access,
  and mentions render as clickable chips with the shared hover user-card.
- Messages composer inserts an atomic, non-editable mention chip so the
  trailing space no longer collapses while typing.
- Links inside chat messages and task comments are underlined/accented so they
  read as clickable.
- Atomization: hold Ctrl/Cmd while dragging an effort slider to rebalance the
  sibling coefficients proportionally (sum stays ~1.00); added a hint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
etalon
2026-06-13 12:09:17 +02:00
parent 70cb664246
commit b232b4b3d3
9 changed files with 239 additions and 59 deletions
+21
View File
@@ -766,6 +766,27 @@ input[type=range] { width: 100%; accent-color: var(--accent); }
border-color: color-mix(in srgb, var(--accent) 70%, black);
}
/* ---- @-mention chips + links inside chat/comments ---- */
.mention {
color: var(--accent);
font-weight: 600;
cursor: pointer;
border-radius: var(--radius);
padding: 0 2px;
white-space: nowrap;
}
.mention:hover {
background: color-mix(in srgb, var(--accent) 16%, transparent);
text-decoration: underline;
}
/* links in messages and comments are underlined so they read as clickable */
.chat-msg a:not(.btn), .cw-msg a:not(.btn), .comment-body a:not(.btn) {
color: var(--accent);
text-decoration: underline;
text-underline-offset: 2px;
font-weight: 500;
}
/* ---- @-mention autocomplete menu ---- */
.mention-menu {
position: absolute; z-index: 80;
+57 -15
View File
@@ -11,6 +11,12 @@ let tasks = [];
let atomizerUp = true;
const selected = new Set();
// Hold Ctrl/Cmd while dragging an effort slider to rebalance siblings.
let ctrlHeld = false;
window.addEventListener('keydown', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = true; });
window.addEventListener('keyup', (e) => { if (e.key === 'Control' || e.key === 'Meta') ctrlHeld = false; });
window.addEventListener('blur', () => { ctrlHeld = false; });
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
@@ -115,14 +121,20 @@ function renderChildren(parent, kids, depth) {
wrap.className = 'stack mt';
const subdivided = kids.filter((k) => k.origin === 'subdivided');
const sum = subdivided.reduce((acc, k) => acc + k.effortCoefficient, 0);
const hasExtension = kids.some((k) => k.origin === 'extended');
if (subdivided.length) {
const ind = document.createElement('p');
const bad = !hasExtension && Math.abs(sum - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${sum.toFixed(2)}</strong>` +
const subSiblings = []; // {k, slider, coeffEl, bountyEl} for adjustable subdivided rows
let ind = null;
function updateSumIndicator() {
if (!ind) return;
const s = subSiblings.reduce((a, x) => a + parseFloat(x.slider.value), 0);
const bad = !hasExtension && Math.abs(s - 1) > 0.005;
ind.innerHTML = `Coefficient sum: <strong style="color:${bad ? 'var(--err)' : 'var(--ok)'}">${s.toFixed(2)}</strong>` +
(hasExtension ? ' <span class="muted">(extensions allow > 1.00)</span>'
: bad ? ' — should be 1.00' : '');
: bad ? ' — should be 1.00' : '') +
' <span class="muted" style="font-size:0.82rem">· hold Ctrl while dragging to rebalance the others</span>';
}
if (subdivided.length) {
ind = document.createElement('p');
wrap.appendChild(ind);
}
@@ -157,25 +169,54 @@ function renderChildren(parent, kids, depth) {
</span>
</div>`;
const slider = row.querySelector('input[type=range]');
const coeffEl = row.querySelector('[data-coeff]');
const bountyEl = row.querySelector('[data-bounty]');
if (k.origin === 'subdivided' && !slider.disabled) {
subSiblings.push({ k, slider, coeffEl, bountyEl });
}
let sliderTimer = null;
const round2 = (n) => Math.round(n * 100) / 100;
slider.addEventListener('input', () => {
row.querySelector('[data-coeff]').textContent = parseFloat(slider.value).toFixed(2);
row.querySelector('[data-bounty]').textContent = (slider.value * k.budget).toFixed(2);
const newVal = parseFloat(slider.value);
coeffEl.textContent = newVal.toFixed(2);
bountyEl.textContent = (newVal * k.budget).toFixed(2);
const changed = [{ k, value: newVal }];
// Ctrl/Cmd held: rebalance the other subdivided siblings so the sum
// stays ~1.00 (increase one → the rest shrink proportionally).
if (ctrlHeld && k.origin === 'subdivided') {
const others = subSiblings.filter((s) => s.k.id !== k.id);
const curOthers = others.reduce((a, s) => a + parseFloat(s.slider.value), 0);
const targetOthers = Math.max(0, 1 - newVal);
if (others.length && curOthers > 0) {
const scale = targetOthers / curOthers;
others.forEach((s) => {
const max = parseFloat(s.slider.max);
const nv = round2(Math.max(0.01, Math.min(max, parseFloat(s.slider.value) * scale)));
s.slider.value = nv;
s.coeffEl.textContent = nv.toFixed(2);
s.bountyEl.textContent = (nv * s.k.budget).toFixed(2);
changed.push({ k: s.k, value: nv });
});
}
updateSumIndicator();
}
clearTimeout(sliderTimer);
sliderTimer = setTimeout(async () => {
try {
const res = await api('PATCH', `/api/v1/tasks/${k.id}`, {
effortCoefficient: parseFloat(slider.value), version: k.version,
});
k.version = res.task.version;
k.effortCoefficient = res.task.effortCoefficient;
k.bounty = res.task.bounty;
for (const ch of changed) {
const res = await api('PATCH', `/api/v1/tasks/${ch.k.id}`, {
effortCoefficient: ch.value, version: ch.k.version,
});
ch.k.version = res.task.version;
ch.k.effortCoefficient = res.task.effortCoefficient;
ch.k.bounty = res.task.bounty;
}
render();
} catch (e) {
if (e.status === 409) { toast('Task changed elsewhere — reloading.', 'err'); load(); }
else toast(e.message, 'err');
}
}, 400);
}, 450);
});
const sel = row.querySelector('[data-sel]');
if (sel) {
@@ -200,6 +241,7 @@ function renderChildren(parent, kids, depth) {
const grand = childrenOf(k.id);
if (grand.length) wrap.appendChild(renderChildren(k, grand, depth + 1));
});
updateSumIndicator();
return wrap;
}
+2 -2
View File
@@ -3,7 +3,7 @@
// plain-text composer. Reuses the conversations API + live WS channel.
import { api } from '/static/js/api.js';
import { subscribe, onPollFallback } from '/static/js/ws.js';
import { attachMentions } from '/static/js/mention.js';
import { attachMentions, mentionHTML } from '/static/js/mention.js';
if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages') {
let meId = '';
@@ -113,7 +113,7 @@ if (document.body.dataset.loggedIn === '1' && location.pathname !== '/messages')
input.value = '';
try {
await api('POST', `/api/v1/conversations/${current.id}/messages`, {
body: '<p>' + esc(text) + '</p>',
body: '<p>' + mentionHTML(text, input) + '</p>',
});
} catch (e) { input.value = text; }
}
+43 -4
View File
@@ -22,6 +22,7 @@ export function attachMentions(el) {
let items = [];
let active = 0;
let seq = 0;
el._mentions = el._mentions || new Map(); // display name -> user id
function close() {
menu.hidden = true;
@@ -89,18 +90,33 @@ export function attachMentions(el) {
}
function pick(u, ctx) {
const text = '@' + u.name + ' ';
el._mentions.set(u.name, u.id);
if (isCE) {
// insert an atomic mention chip + a trailing space in the text node
const node = ctx.node;
const t = node.textContent;
node.textContent = t.slice(0, ctx.start) + text + t.slice(ctx.end);
const full = node.textContent;
const after = full.slice(ctx.end);
node.textContent = full.slice(0, ctx.start);
const span = document.createElement('span');
span.className = 'mention';
span.dataset.userCard = u.id;
span.contentEditable = 'false';
span.textContent = '@' + u.name;
// the chip is an atomic, non-editable element, so the trailing space in
// this separate text node stays put as the user keeps typing
const tail = document.createTextNode(' ' + after);
const parent = node.parentNode;
const ref = node.nextSibling;
parent.insertBefore(span, ref);
parent.insertBefore(tail, ref);
const sel = window.getSelection();
const range = document.createRange();
range.setStart(node, ctx.start + text.length);
range.setStart(tail, 1);
range.collapse(true);
sel.removeAllRanges();
sel.addRange(range);
} else {
const text = '@' + u.name + ' ';
const v = el.value;
el.value = v.slice(0, ctx.start) + text + v.slice(ctx.end);
const caret = ctx.start + text.length;
@@ -122,6 +138,29 @@ export function attachMentions(el) {
el.addEventListener('blur', () => setTimeout(close, 150));
}
// mentionHTML turns the plain text of a textarea/input into safe HTML, wrapping
// any recorded "@Name" run in a mention span carrying the user id. Names with
// spaces are matched exactly (longest first) against what the picker inserted.
export function mentionHTML(text, el) {
const mentions = (el && el._mentions) || new Map();
const names = [...mentions.keys()].sort((a, b) => b.length - a.length);
let out = '';
let i = 0;
while (i < text.length) {
if (text[i] === '@') {
const name = names.find((n) => text.startsWith('@' + n, i));
if (name) {
out += `<span class="mention" data-user-card="${escapeHtml(mentions.get(name))}">@${escapeHtml(name)}</span>`;
i += 1 + name.length;
continue;
}
}
out += escapeHtml(text[i]);
i += 1;
}
return out;
}
function escapeHtml(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
+8
View File
@@ -81,3 +81,11 @@ document.addEventListener('mouseout', (e) => {
hideTimer = setTimeout(hideCard, 250);
}
});
// click a mention (or any user-card target) to open its card immediately
document.addEventListener('click', (e) => {
const target = e.target.closest('[data-user-card]');
if (!target) return;
e.preventDefault();
clearTimeout(hideTimer);
showCard(target, target.dataset.userCard);
});
+5 -4
View File
@@ -1,6 +1,6 @@
import { api, toast } from '/static/js/api.js';
import { subscribe } from '/static/js/ws.js';
import { attachMentions } from '/static/js/mention.js';
import { attachMentions, mentionHTML } from '/static/js/mention.js';
const root = document.getElementById('task-root');
const errorBox = document.getElementById('error');
@@ -58,7 +58,7 @@ async function render() {
const comments = await Promise.all((t.comments || []).map(async (c) => `
<div class="card" style="padding:12px">
<p class="muted" style="margin:0 0 8px">${esc(await userName(c.authorId))} · ${new Date(c.at).toLocaleString()}</p>
<div>${c.body}</div>
<div class="comment-body">${c.body}</div>
</div>`));
const timeline = await Promise.all((t.timeline || []).slice().reverse().map(async (e) => `
@@ -183,9 +183,10 @@ function wireHandlers() {
});
document.getElementById('comment-form').addEventListener('submit', async (e) => {
e.preventDefault();
const body = document.getElementById('comment-body').value;
const field = document.getElementById('comment-body');
const body = mentionHTML(field.value, field).replace(/\n/g, '<br>');
try {
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + esc(body).replace(/\n/g, '<br>') + '</p>' });
await api('POST', `/api/v1/tasks/${taskId}/comments`, { body: '<p>' + body + '</p>' });
load();
} catch (err) { toast(err.message, 'err'); }
});