fix(conversazioni): il menu dei tag mostra una riga per persona, non per alias

Con un cliente «Gian Luca Caruso / Caruso Speaker» il menu proponeva tre voci
per un destinatario solo. Non era un difetto di resa: al menu era stata passata
la stessa lista che il parser usa per RICONOSCERE un tag rileggendo il testo.

Sono due domande diverse, e il codice le aveva confuse:
  - come lo si puo' scrivere -> tutti gli alias, invisibili, solo in lettura
  - chi si puo' scegliere    -> una riga per destinatario

`MentionTarget` le separa: `label` per il menu, `aliases` per la rilettura.
Scrivere «@Gian» a mano continua a valere come tag e a far partire la mail --
cambia solo cosa viene offerto, non cosa viene accettato.

Il filtro del menu ora passa da `normalizeForSearch`, la stessa
normalizzazione del parser: con un toLowerCase() a parte, digitare «nicolo»
non avrebbe trovato «Nicolò» nel menu mentre nel messaggio sarebbe stato
riconosciuto -- due regole per la stessa domanda, e la seconda si scopre solo
quando la mail non parte.

Il difetto scalava peggio del valore: con tre contatti per cliente sarebbero
diventate nove righe. `mentionTargets()` ritorna gia' un array per questo, ma
il tag per singola persona resta impossibile finche' `client_emails` ha gli
indirizzi e non i nomi: serve una migration, ed e' un lavoro a se'.

Verificato: build e lint puliti, 23 test su parser e menu. Mai visto a schermo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-01 11:22:38 +02:00
parent 614eb00040
commit 9ce6f02ee4
5 changed files with 116 additions and 26 deletions
@@ -290,7 +290,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
key={m.id}
message={m}
clientId={thread.clientId}
mentionCandidates={thread.mentionCandidates}
mentionCandidates={thread.mentionAliases}
/>
))
)}
@@ -302,7 +302,7 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
channelKey={activeChannel}
channelLabel={activeLabel}
isGeneral={isGeneral}
mentionCandidates={thread.mentionCandidates}
mentionTargets={thread.mentionTargets}
notifyEmailCount={thread.notifyEmailCount}
/>
</>
@@ -5,6 +5,7 @@ import { AtSign } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import { normalizeForSearch, type MentionTarget } from "@/lib/mentions";
import { replyToConversation } from "@/app/admin/conversazioni/actions";
/**
@@ -20,15 +21,19 @@ export function ReplyComposer({
channelKey,
channelLabel,
isGeneral,
mentionCandidates,
mentionTargets,
notifyEmailCount,
}: {
clientId: string;
channelKey: string;
channelLabel: string;
isGeneral: boolean;
/** I nomi con cui si può taggare questo cliente, dal più completo al più corto. */
mentionCandidates: string[];
/**
* Chi si può taggare: **una voce per destinatario**, non una per modo di
* scriverlo. Prima qui arrivava la lista piatta degli alias e il menu mostrava
* tre righe — nome, brand, nome di battesimo — per una persona sola.
*/
mentionTargets: MentionTarget[];
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
notifyEmailCount: number;
}) {
@@ -43,10 +48,14 @@ export function ReplyComposer({
const suggestions = useMemo(() => {
if (!trigger) return [];
const q = trigger.query.toLowerCase();
// Il nome intero per primo: è quello che si vuole quasi sempre.
return mentionCandidates.filter((c) => c.toLowerCase().startsWith(q)).slice(0, 4);
}, [trigger, mentionCandidates]);
const q = normalizeForSearch(trigger.query);
// Si filtra sugli alias ma si tiene il TARGET: digitando «car» il cliente si
// trova passando dal brand, e resta comunque una riga sola col suo nome.
// La normalizzazione è quella del parser, così «nicolo» trova «Nicolò».
return mentionTargets
.filter((t) => t.aliases.some((a) => normalizeForSearch(a).startsWith(q)))
.slice(0, 6);
}, [trigger, mentionTargets]);
const open = suggestions.length > 0;
@@ -122,8 +131,8 @@ export function ReplyComposer({
aria-label="Tagga il cliente"
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
>
{suggestions.map((candidate, i) => (
<li key={candidate}>
{suggestions.map((target, i) => (
<li key={target.label}>
<button
type="button"
role="option"
@@ -133,7 +142,7 @@ export function ReplyComposer({
// nel posto sbagliato.
onMouseDown={(e) => {
e.preventDefault();
insert(candidate);
insert(target.label);
}}
className={cn(
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
@@ -141,7 +150,7 @@ export function ReplyComposer({
)}
>
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
<span className="truncate font-medium text-foreground">{candidate}</span>
<span className="truncate font-medium text-foreground">{target.label}</span>
</button>
</li>
))}
@@ -162,7 +171,7 @@ export function ReplyComposer({
onKeyDown={(e) => {
if (open && (e.key === "Enter" || e.key === "Tab")) {
e.preventDefault();
insert(suggestions[0]);
insert(suggestions[0].label);
return;
}
if (e.key === "Escape" && open) {