feat(webui): preview dragged session mentions

This commit is contained in:
Xubin Ren 2026-08-06 11:34:12 +08:00
parent 9cf6cf0639
commit f44a766f98
5 changed files with 162 additions and 31 deletions

View File

@ -40,7 +40,7 @@ import {
visibleSessionsForGroup, visibleSessionsForGroup,
type ChatGroupLabels, type ChatGroupLabels,
} from "@/lib/chat-groups"; } from "@/lib/chat-groups";
import { writeDraggedSession } from "@/lib/session-drag"; import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types"; import type { ChatSummary, SidebarDensity, SidebarSortMode } from "@/lib/types";
@ -357,6 +357,7 @@ export const ChatList = memo(function ChatList({
writeDraggedSession(event.dataTransfer, s.key); writeDraggedSession(event.dataTransfer, s.key);
}} }}
onDragEnd={() => { onDragEnd={() => {
clearDraggedSession();
setDraggedSessionKey(null); setDraggedSessionKey(null);
setSessionDropTarget(null); setSessionDropTarget(null);
}} }}

View File

@ -106,6 +106,7 @@ import {
slashCommandLifecycle, slashCommandLifecycle,
} from "@/lib/slash-command"; } from "@/lib/slash-command";
import { import {
clearDraggedSession,
hasDraggedSession, hasDraggedSession,
readDraggedSession, readDraggedSession,
} from "@/lib/session-drag"; } 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 { function sessionMentionBase(session: ChatSummary): string {
const label = session.title?.trim() || session.preview.trim() || "session"; const label = session.title?.trim() || session.preview.trim() || "session";
const slug = label const slug = label
@ -940,6 +970,11 @@ export function ThreadComposer({
const { t } = useTranslation(); const { t } = useTranslation();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]); const [selectedSessionMentions, setSelectedSessionMentions] = useState<SessionMention[]>([]);
const [sessionDragPreview, setSessionDragPreview] = useState<{
mention: SessionMention;
start: number;
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(null); const [inlineError, setInlineError] = useState<string | null>(null);
const [voiceErrorFading, setVoiceErrorFading] = useState(false); const [voiceErrorFading, setVoiceErrorFading] = useState(false);
const [slashMenuDismissed, setSlashMenuDismissed] = useState(false); const [slashMenuDismissed, setSlashMenuDismissed] = useState(false);
@ -1267,6 +1302,22 @@ export function ThreadComposer({
() => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions), () => splitCapabilityMentionSegments(value, cliApps, mcpPresets, selectedSessionMentions),
[cliApps, mcpPresets, selectedSessionMentions, value], [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 activeSessionMentions = useMemo(() => {
const seen = new Set<string>(); const seen = new Set<string>();
return mentionSegments.flatMap((segment) => { return mentionSegments.flatMap((segment) => {
@ -1355,7 +1406,7 @@ export function ThreadComposer({
const showCliAppMenu = filteredMentionCandidates.length > 0; const showCliAppMenu = filteredMentionCandidates.length > 0;
const showAnyPalette = showSlashMenu || showCliAppMenu; const showAnyPalette = showSlashMenu || showCliAppMenu;
const hasMentionDecorations = mentionSegments.some( const hasMentionDecorations = displayMentionSegments.some(
(segment) => segment.kind !== "text", (segment) => segment.kind !== "text",
); );
const activeCliMentionApps = useMemo(() => { const activeCliMentionApps = useMemo(() => {
@ -1626,15 +1677,9 @@ export function ThreadComposer({
candidate.mention, candidate.mention,
]); ]);
} }
const prefix = value.slice(0, start); const insertion = mentionInsertion(value, candidate.name, start, end);
const suffix = value.slice(end); setValue(insertion.value);
const leadingSpace = prefix && !/\s$/.test(prefix) ? " " : ""; setCursorPosition(insertion.cursor);
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);
setCliAppMenuDismissed(true); setCliAppMenuDismissed(true);
setSlashMenuDismissed(false); setSlashMenuDismissed(false);
setInlineError(null); setInlineError(null);
@ -1643,7 +1688,7 @@ export function ThreadComposer({
const el = textareaRef.current; const el = textareaRef.current;
if (!el) return; if (!el) return;
el.focus(); el.focus();
el.setSelectionRange(nextCursor, nextCursor); el.setSelectionRange(insertion.cursor, insertion.cursor);
}); });
}, },
[activeSessionMentions, resizeTextarea, value], [activeSessionMentions, resizeTextarea, value],
@ -1660,13 +1705,16 @@ export function ThreadComposer({
const handleSessionDrop = useCallback((event: React.DragEvent) => { const handleSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false; if (!hasDraggedSession(event.dataTransfer)) return false;
event.preventDefault(); event.preventDefault();
clearDraggedSession();
const preview = sessionDragPreview;
setSessionDragPreview(null);
if (disabled) return true; if (disabled) return true;
const sessionKey = readDraggedSession(event.dataTransfer); const sessionKey = readDraggedSession(event.dataTransfer);
const mention = availableSessionMentions.find( const mention = availableSessionMentions.find(
(candidate) => candidate.session_key === sessionKey, (candidate) => candidate.session_key === (sessionKey ?? preview?.mention.session_key),
); );
if (!mention) return true; if (!mention) return true;
const caret = textareaRef.current?.selectionStart ?? value.length; const caret = preview?.start ?? textareaRef.current?.selectionStart ?? value.length;
insertMentionCandidate( insertMentionCandidate(
{ {
kind: "session", kind: "session",
@ -1675,10 +1723,51 @@ export function ThreadComposer({
mention, mention,
}, },
caret, caret,
textareaRef.current?.selectionEnd ?? caret, preview?.end ?? textareaRef.current?.selectionEnd ?? caret,
); );
return true; 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) => { const clearComposerText = useCallback((restoreFocus = true) => {
setValue(""); setValue("");
@ -2108,16 +2197,22 @@ export function ThreadComposer({
e.preventDefault(); e.preventDefault();
submit(); submit();
}} }}
onDragEnter={onDragEnter} onDragEnter={(event) => {
if (!previewSessionDrop(event)) onDragEnter(event);
}}
onDragOver={(event) => { onDragOver={(event) => {
if (hasDraggedSession(event.dataTransfer)) { if (!previewSessionDrop(event)) onDragOver(event);
event.preventDefault(); }}
event.dataTransfer.dropEffect = "copy"; onDragLeave={(event) => {
} else { if (!hasDraggedSession(event.dataTransfer)) {
onDragOver(event); onDragLeave(event);
return;
}
const nextTarget = event.relatedTarget;
if (!(nextTarget instanceof Node) || !event.currentTarget.contains(nextTarget)) {
setSessionDragPreview(null);
} }
}} }}
onDragLeave={onDragLeave}
onDrop={(event) => { onDrop={(event) => {
if (!handleSessionDrop(event)) 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-[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]", : "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", disabled && "opacity-60",
sessionDragPreview && "ring-1 ring-primary/25",
isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary", isDragging && "ring-2 ring-primary/40 motion-reduce:ring-0 motion-reduce:border-primary",
goalState?.active && goalState?.active &&
"goal-shell-glow ring-1 ring-sky-400/35 motion-reduce:ring-sky-400/25 dark:ring-sky-400/45", "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({
<div className="relative"> <div className="relative">
{hasMentionDecorations ? ( {hasMentionDecorations ? (
<ComposerCliMentionOverlay <ComposerCliMentionOverlay
segments={mentionSegments} segments={displayMentionSegments}
isHero={isHero} isHero={isHero}
className={inputTextClasses} className={inputTextClasses}
ghostRange={sessionDragInsertion
? { start: sessionDragInsertion.tokenStart, end: sessionDragInsertion.tokenEnd }
: null}
/> />
) : null} ) : null}
<textarea <textarea
@ -2259,7 +2358,7 @@ export function ThreadComposer({
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)} onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste} onPaste={onPaste}
rows={1} rows={1}
placeholder={resolvedPlaceholder} placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled} disabled={disabled}
aria-label={t("thread.composer.inputAria")} aria-label={t("thread.composer.inputAria")}
className={cn( className={cn(
@ -2646,11 +2745,14 @@ function ComposerCliMentionOverlay({
segments, segments,
isHero, isHero,
className, className,
ghostRange,
}: { }: {
segments: CapabilityMentionSegment[]; segments: CapabilityMentionSegment[];
isHero: boolean; isHero: boolean;
className: string; className: string;
ghostRange?: { start: number; end: number } | null;
}) { }) {
let offset = 0;
return ( return (
<div <div
aria-hidden aria-hidden
@ -2660,16 +2762,24 @@ function ComposerCliMentionOverlay({
)} )}
> >
{segments.map((segment, index) => { {segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
if (segment.kind === "text") { if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>; return <span key={`text-${index}`}>{segment.text}</span>;
} }
const isGhost = ghostRange?.start === start && ghostRange.end === offset;
return ( return (
<CapabilityMentionToken <span
key={`${segment.kind}-${index}`} key={`${segment.kind}-${index}`}
segment={segment} data-testid={isGhost ? "composer-session-drag-preview" : undefined}
variant="composer" className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
isHero={isHero} >
/> <CapabilityMentionToken
segment={segment}
variant="composer"
isHero={isHero}
/>
</span>
); );
})} })}
</div> </div>

View File

@ -1,18 +1,25 @@
export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key"; export const SESSION_DRAG_TYPE = "application/x-nanobot-session-key";
let activeSessionKey: string | null = null;
export function hasDraggedSession(dataTransfer: DataTransfer): boolean { export function hasDraggedSession(dataTransfer: DataTransfer): boolean {
return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE); return Array.from(dataTransfer.types).includes(SESSION_DRAG_TYPE);
} }
export function readDraggedSession(dataTransfer: DataTransfer): string | null { export function readDraggedSession(dataTransfer: DataTransfer): string | null {
const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim(); const sessionKey = dataTransfer.getData(SESSION_DRAG_TYPE).trim();
return sessionKey || null; return sessionKey || activeSessionKey;
}
export function clearDraggedSession(): void {
activeSessionKey = null;
} }
export function writeDraggedSession( export function writeDraggedSession(
dataTransfer: DataTransfer, dataTransfer: DataTransfer,
sessionKey: string, sessionKey: string,
): void { ): void {
activeSessionKey = sessionKey;
dataTransfer.effectAllowed = "copyMove"; dataTransfer.effectAllowed = "copyMove";
dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey); dataTransfer.setData(SESSION_DRAG_TYPE, sessionKey);
} }

View File

@ -79,6 +79,7 @@ describe("ChatList", () => {
SESSION_DRAG_TYPE, SESSION_DRAG_TYPE,
"websocket:reference", "websocket:reference",
); );
fireEvent.dragEnd(reference, { dataTransfer });
}); });
it("reorders chats around a Codex-style insertion line", () => { it("reorders chats around a Codex-style insertion line", () => {

View File

@ -1619,9 +1619,21 @@ describe("ThreadComposer", () => {
fireEvent.dragEnter(input, { dataTransfer }); fireEvent.dragEnter(input, { dataTransfer });
fireEvent.dragOver(input, { dataTransfer }); fireEvent.dragOver(input, { dataTransfer });
expect(input).toHaveValue("Compare notes");
expect(screen.getByTestId("composer-session-drag-preview"))
.toHaveTextContent("@收费设计");
fireEvent.dragEnd(document);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
fireEvent.dragEnter(input, { dataTransfer });
fireEvent.dragOver(input, { dataTransfer });
fireEvent.drop(input, { dataTransfer }); fireEvent.drop(input, { dataTransfer });
expect(input).toHaveValue("Compare @收费设计 notes"); expect(input).toHaveValue("Compare @收费设计 notes");
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
expect(screen.getByTestId("composer-session-mention-收费设计")) expect(screen.getByTestId("composer-session-mention-收费设计"))
.toHaveTextContent("@收费设计"); .toHaveTextContent("@收费设计");