diff --git a/webui/src/components/ChatList.tsx b/webui/src/components/ChatList.tsx index d64a5792c..e7fdea0e8 100644 --- a/webui/src/components/ChatList.tsx +++ b/webui/src/components/ChatList.tsx @@ -40,7 +40,7 @@ import { visibleSessionsForGroup, type ChatGroupLabels, } from "@/lib/chat-groups"; -import { writeDraggedSession } from "@/lib/session-drag"; +import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag"; import { cn } from "@/lib/utils"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; @@ -357,6 +357,7 @@ export const ChatList = memo(function ChatList({ writeDraggedSession(event.dataTransfer, s.key); }} onDragEnd={() => { + clearDraggedSession(); setDraggedSessionKey(null); setSessionDropTarget(null); }} diff --git a/webui/src/components/thread/ThreadComposer.tsx b/webui/src/components/thread/ThreadComposer.tsx index c11de8373..6dc4745ca 100644 --- a/webui/src/components/thread/ThreadComposer.tsx +++ b/webui/src/components/thread/ThreadComposer.tsx @@ -106,6 +106,7 @@ import { slashCommandLifecycle, } from "@/lib/slash-command"; import { + clearDraggedSession, hasDraggedSession, readDraggedSession, } from "@/lib/session-drag"; @@ -321,6 +322,35 @@ type MentionCandidate = { } ); +interface MentionInsertion { + value: string; + cursor: number; + tokenStart: number; + tokenEnd: number; +} + +function mentionInsertion( + value: string, + name: string, + start: number, + end: number, +): MentionInsertion { + const from = Math.min(Math.max(start, 0), value.length); + const to = Math.min(Math.max(end, from), value.length); + const prefix = value.slice(0, from); + const suffix = value.slice(to); + const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : ""; + const trailingSpace = /^\s/.test(suffix) ? "" : " "; + const tokenStart = prefix.length + leadingSpace.length; + const tokenEnd = tokenStart + name.length + 1; + return { + value: `${prefix}${leadingSpace}@${name}${trailingSpace}${suffix}`, + cursor: tokenEnd + trailingSpace.length, + tokenStart, + tokenEnd, + }; +} + function sessionMentionBase(session: ChatSummary): string { const label = session.title?.trim() || session.preview.trim() || "session"; const slug = label @@ -940,6 +970,11 @@ export function ThreadComposer({ const { t } = useTranslation(); const [value, setValue] = useState(""); const [selectedSessionMentions, setSelectedSessionMentions] = useState([]); + const [sessionDragPreview, setSessionDragPreview] = useState<{ + mention: SessionMention; + start: number; + end: number; + } | null>(null); const [inlineError, setInlineError] = useState(null); const [voiceErrorFading, setVoiceErrorFading] = useState(false); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); @@ -1267,6 +1302,22 @@ export function ThreadComposer({ () => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions), [cliApps, mcpPresets, selectedSessionMentions, value], ); + const sessionDragInsertion = sessionDragPreview + ? mentionInsertion( + value, + sessionDragPreview.mention.name, + sessionDragPreview.start, + sessionDragPreview.end, + ) + : null; + const displayMentionSegments = sessionDragInsertion && sessionDragPreview + ? splitCapabilityMentionSegments( + sessionDragInsertion.value, + cliApps, + mcpPresets, + [...selectedSessionMentions, sessionDragPreview.mention], + ) + : mentionSegments; const activeSessionMentions = useMemo(() => { const seen = new Set(); return mentionSegments.flatMap((segment) => { @@ -1355,7 +1406,7 @@ export function ThreadComposer({ const showCliAppMenu = filteredMentionCandidates.length > 0; const showAnyPalette = showSlashMenu || showCliAppMenu; - const hasMentionDecorations = mentionSegments.some( + const hasMentionDecorations = displayMentionSegments.some( (segment) => segment.kind !== "text", ); const activeCliMentionApps = useMemo(() => { @@ -1626,15 +1677,9 @@ export function ThreadComposer({ candidate.mention, ]); } - const prefix = value.slice(0, start); - const suffix = value.slice(end); - const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : ""; - const trailingSpace = /^\s/.test(suffix) ? "" : " "; - const mention = `${leadingSpace}@${candidate.name}${trailingSpace}`; - const next = `${prefix}${mention}${suffix}`; - const nextCursor = prefix.length + mention.length; - setValue(next); - setCursorPosition(nextCursor); + const insertion = mentionInsertion(value, candidate.name, start, end); + setValue(insertion.value); + setCursorPosition(insertion.cursor); setCliAppMenuDismissed(true); setSlashMenuDismissed(false); setInlineError(null); @@ -1643,7 +1688,7 @@ export function ThreadComposer({ const el = textareaRef.current; if (!el) return; el.focus(); - el.setSelectionRange(nextCursor, nextCursor); + el.setSelectionRange(insertion.cursor, insertion.cursor); }); }, [activeSessionMentions, resizeTextarea, value], @@ -1660,13 +1705,16 @@ export function ThreadComposer({ const handleSessionDrop = useCallback((event: React.DragEvent) => { if (!hasDraggedSession(event.dataTransfer)) return false; event.preventDefault(); + clearDraggedSession(); + const preview = sessionDragPreview; + setSessionDragPreview(null); if (disabled) return true; const sessionKey = readDraggedSession(event.dataTransfer); const mention = availableSessionMentions.find( - (candidate) => candidate.session_key === sessionKey, + (candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key), ); if (!mention) return true; - const caret = textareaRef.current?.selectionStart ?? value.length; + const caret = preview?.start ?? textareaRef.current?.selectionStart ?? value.length; insertMentionCandidate( { kind: "session", @@ -1675,10 +1723,51 @@ export function ThreadComposer({ mention, }, caret, - textareaRef.current?.selectionEnd ?? caret, + preview?.end ?? textareaRef.current?.selectionEnd ?? caret, ); return true; - }, [availableSessionMentions, disabled, insertMentionCandidate, value.length]); + }, [availableSessionMentions, disabled, insertMentionCandidate, sessionDragPreview, value.length]); + + const previewSessionDrop = useCallback((event: React.DragEvent) => { + if (!hasDraggedSession(event.dataTransfer)) return false; + event.preventDefault(); + event.dataTransfer.dropEffect = "copy"; + if (disabled) { + setSessionDragPreview(null); + return true; + } + const sessionKey = readDraggedSession(event.dataTransfer); + const mention = availableSessionMentions.find( + (candidate) => candidate.session_key === sessionKey, + ); + const alreadySelected = mention && activeSessionMentions.some( + (candidate) => candidate.session_key === mention.session_key, + ); + if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) { + setSessionDragPreview(null); + return true; + } + const start = textareaRef.current?.selectionStart ?? value.length; + const end = textareaRef.current?.selectionEnd ?? start; + setSessionDragPreview((current) => ( + current?.mention.session_key === mention.session_key + && current.start === start + && current.end === end + ? current + : { mention, start, end } + )); + return true; + }, [activeSessionMentions, availableSessionMentions, disabled, value.length]); + + useEffect(() => { + if (!sessionDragPreview) return; + const clearPreview = () => { + clearDraggedSession(); + setSessionDragPreview(null); + }; + document.addEventListener("dragend", clearPreview); + return () => document.removeEventListener("dragend", clearPreview); + }, [sessionDragPreview]); const clearComposerText = useCallback((restoreFocus = true) => { setValue(""); @@ -2108,16 +2197,22 @@ export function ThreadComposer({ e.preventDefault(); submit(); }} - onDragEnter={onDragEnter} + onDragEnter={(event) => { + if (!previewSessionDrop(event)) onDragEnter(event); + }} onDragOver={(event) => { - if (hasDraggedSession(event.dataTransfer)) { - event.preventDefault(); - event.dataTransfer.dropEffect = "copy"; - } else { - onDragOver(event); + if (!previewSessionDrop(event)) onDragOver(event); + }} + onDragLeave={(event) => { + if (!hasDraggedSession(event.dataTransfer)) { + onDragLeave(event); + return; + } + const nextTarget = event.relatedTarget; + if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) { + setSessionDragPreview(null); } }} - onDragLeave={onDragLeave} onDrop={(event) => { if (!handleSessionDrop(event)) onDrop(event); }} @@ -2150,6 +2245,7 @@ export function ThreadComposer({ ? "max-w-[58rem] rounded-[28px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]" : "max-w-[49.5rem] rounded-[22px] bg-muted/30 focus-within:bg-muted/50 dark:bg-card dark:focus-within:bg-white/[0.06]", disabled && "opacity-60", + sessionDragPreview && "ring-1 ring-primary/25", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", goalState?.active && "goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", @@ -2234,9 +2330,12 @@ export function ThreadComposer({
{hasMentionDecorations ? ( ) : null}