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
+23 -25
View File
@@ -336,7 +336,9 @@ func (s *Server) handleAbandonTask(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusNoContent)
}
var mentionRe = regexp.MustCompile(`@([\w.+-]+@[\w.-]+\.\w+|\w+)`)
// mentionUIDRe pulls user ids out of mention spans the sanitizer emitted:
// <span class="mention" data-user-card="ULID">@Name</span>.
var mentionUIDRe = regexp.MustCompile(`data-user-card="([0-9A-Za-z]+)"`)
func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
var req struct {
@@ -392,43 +394,39 @@ func (s *Server) handleAddComment(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusCreated, map[string]any{"comment": comment})
}
// notifyMentions resolves @name / @email tokens against task participants
// (§11.3).
// notifyMentions notifies users referenced by mention spans in the comment
// body, but only those who actually have access to the task (§11.3).
func (s *Server) notifyMentions(r *http.Request, t *domain.Task, author *domain.User, body string) {
plain := chat.SanitizePlain(body)
matches := mentionRe.FindAllStringSubmatch(plain, 10)
matches := mentionUIDRe.FindAllStringSubmatch(body, 20)
if len(matches) == 0 {
return
}
// candidate participants
ids := map[string]bool{t.ConsultantID: true}
ctx := r.Context()
// who can see this task: its consultants, assignee, claimers, commenters
access := map[string]bool{t.ConsultantID: true}
if c, err := s.store.CustomerByID(ctx, t.CustomerID); err == nil {
for _, id := range c.ConsultantIDs {
access[id] = true
}
}
if t.Assignee != nil && t.Assignee.UserID != "" {
ids[t.Assignee.UserID] = true
access[t.Assignee.UserID] = true
}
for _, cr := range t.ClaimRequests {
ids[cr.DeveloperID] = true
access[cr.DeveloperID] = true
}
for _, c := range t.Comments {
ids[c.AuthorID] = true
access[c.AuthorID] = true
}
notified := map[string]bool{}
for _, m := range matches {
token := strings.ToLower(m[1])
for id := range ids {
if id == author.ID || notified[id] {
continue
}
u, err := s.store.UserByID(r.Context(), id)
if err != nil {
continue
}
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
if token == strings.ToLower(u.Email) || token == first {
notified[id] = true
s.notifyUser(r.Context(), id, "mention",
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
}
uid := m[1]
if uid == author.ID || notified[uid] || !access[uid] {
continue
}
notified[uid] = true
s.notifyUser(ctx, uid, "mention",
author.Name+" mentioned you", "On task: "+t.Title, "/tasks/"+t.ID)
}
}
+11 -9
View File
@@ -232,19 +232,21 @@ func (s *Server) handleSendMessage(w http.ResponseWriter, r *http.Request) {
if s.sendTo != nil {
s.sendTo(conv.ParticipantIDs, "chat", "message", msg)
}
// Notify mentioned users who are participants (i.e. have access §11.1).
plain := chat.SanitizePlain(body)
participants := map[string]bool{}
for _, pid := range conv.ParticipantIDs {
if pid == me.ID {
participants[pid] = true
}
notified := map[string]bool{}
for _, m := range mentionUIDRe.FindAllStringSubmatch(body, 20) {
uid := m[1]
if uid == me.ID || notified[uid] || !participants[uid] {
continue
}
if u, err := s.store.UserByID(r.Context(), pid); err == nil {
first := strings.ToLower(strings.SplitN(u.Name, " ", 2)[0])
lower := strings.ToLower(plain)
if strings.Contains(lower, "@"+strings.ToLower(u.Email)) || strings.Contains(lower, "@"+first) {
s.notifyUser(r.Context(), pid, "mention",
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
}
}
notified[uid] = true
s.notifyUser(r.Context(), uid, "mention",
me.Name+" mentioned you in a chat", truncateStr(plain, 120), "/messages?c="+conv.ID)
}
writeJSON(w, http.StatusCreated, map[string]any{"message": msg})
}