import { useEffect, useMemo, useRef, useState } from 'react'; import * as api from '../api/client'; import type { SessionCandidate } from '../types'; /** * 三段式地址输入:name -> @path -> .session * 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。 * 值本身始终是完整字符串 name@path.session。 * * session 段的候选带标题与来源标记:一个工作区下可能有十几条会话, * 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。 */ export default function AddressInput({ value, onChange, placeholder, allowMultiple = false, autoFocus = false }: { value: string; onChange: (v: string) => void; placeholder?: string; /** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */ allowMultiple?: boolean; autoFocus?: boolean; }) { const [open, setOpen] = useState(false); const [items, setItems] = useState([]); // session 段的富候选,与 items 同序。其他段为空数组。 const [meta, setMeta] = useState([]); const [kind, setKind] = useState<'name' | 'path' | 'session'>('name'); const [active, setActive] = useState(0); const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 }); const boxRef = useRef(null); // 当前正在编辑的那一段(多地址时取最后一段) const { head, editing } = useMemo(() => { if (!allowMultiple) return { head: '', editing: value }; const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';')); if (idx < 0) return { head: '', editing: value }; return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() }; }, [value, allowMultiple]); // 把编辑段拆成 name / path / session 三部分 const parts = useMemo(() => parseParts(editing), [editing]); useEffect(() => { let cancelled = false; const run = async () => { try { // 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session const res = parts.hasDot ? await api.suggestAddress(parts.name, parts.path) : parts.hasAt ? await api.suggestAddress(parts.name) : await api.suggestAddress(); if (cancelled) return; const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name; const lower = frag.toLowerCase(); const all = res.suggestions || []; const cands = res.candidates || []; // 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的, // 分别过滤两个数组会让标题错位到别的别名上。 const keep: number[] = []; all.forEach((s, i) => { const c = cands[i]; // 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名 const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase(); if (hay.includes(lower)) keep.push(i); }); setKind(res.kind); setItems(keep.map(i => all[i])); setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []); setActive(0); } catch { if (!cancelled) { setItems([]); setMeta([]); } } }; const t = setTimeout(run, 120); return () => { cancelled = true; clearTimeout(t); }; }, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]); useEffect(() => { const onDocClick = (e: MouseEvent) => { if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false); }; document.addEventListener('mousedown', onDocClick); return () => document.removeEventListener('mousedown', onDocClick); }, []); // 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。 useEffect(() => { if (!open || items.length === 0) return; const update = () => { const box = boxRef.current; if (!box) return; const rect = box.getBoundingClientRect(); const viewport = window.visualViewport; const top = viewport?.offsetTop ?? 0; const bottom = top + (viewport?.height ?? window.innerHeight); const above = Math.max(0, rect.top - top - 8); const below = Math.max(0, bottom - rect.bottom - 8); const flip = below < Math.min(240, above) && above > below; setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) }); }; update(); window.addEventListener('resize', update, { passive: true }); window.addEventListener('scroll', update, { passive: true, capture: true }); window.visualViewport?.addEventListener('resize', update, { passive: true }); window.visualViewport?.addEventListener('scroll', update, { passive: true }); return () => { window.removeEventListener('resize', update); window.removeEventListener('scroll', update, { capture: true }); window.visualViewport?.removeEventListener('resize', update); window.visualViewport?.removeEventListener('scroll', update); }; }, [open, items.length]); /** 选中一个候选后拼回完整地址 */ const apply = (choice: string) => { let next: string; if (kind === 'name') { next = `${choice}@`; } else if (kind === 'path') { next = `${parts.name}@${choice}.`; } else { next = `${parts.name}@${parts.path}.${choice}`; } onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next); // name/path 选完仍停留在补全态,继续下一段 setOpen(kind !== 'session'); }; const onKeyDown = (e: React.KeyboardEvent) => { if (!open || items.length === 0) return; if (e.key === 'ArrowDown') { e.preventDefault(); setActive(i => (i + 1) % items.length); } else if (e.key === 'ArrowUp') { e.preventDefault(); setActive(i => (i - 1 + items.length) % items.length); } else if (e.key === 'Enter' || e.key === 'Tab') { e.preventDefault(); apply(items[active]); } else if (e.key === 'Escape') { setOpen(false); } }; const hint = kind === 'name' ? 'Agent 名' : kind === 'path' ? '工作区路径' : '会话别名(new 为新建)'; return (
{ onChange(e.target.value); setOpen(true); }} onFocus={() => setOpen(true)} onKeyDown={onKeyDown} placeholder={placeholder} spellCheck={false} className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400" /> {open && items.length > 0 && (
{hint}
{items.map((s, i) => { const c = meta[i]; return ( ); })}
)}
); } /** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */ function parseParts(s: string) { const at = s.indexOf('@'); if (at < 0) { return { name: s, path: '', session: '', hasAt: false, hasDot: false }; } const name = s.slice(0, at); const rest = s.slice(at + 1); const dot = rest.lastIndexOf('.'); if (dot < 0) { return { name, path: rest, session: '', hasAt: true, hasDot: false }; } return { name, path: rest.slice(0, dot), session: rest.slice(dot + 1), hasAt: true, hasDot: true }; }