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,
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);
}}

View File

@ -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<SessionMention[]>([]);
const [sessionDragPreview, setSessionDragPreview] = useState<{
mention: SessionMention;
start: number;
end: number;
} | null>(null);
const [inlineError, setInlineError] = useState<string | null>(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<string>();
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({
<div className="relative">
{hasMentionDecorations ? (
<ComposerCliMentionOverlay
segments={mentionSegments}
segments={displayMentionSegments}
isHero={isHero}
className={inputTextClasses}
ghostRange={sessionDragInsertion
? { start: sessionDragInsertion.tokenStart, end: sessionDragInsertion.tokenEnd }
: null}
/>
) : null}
<textarea
@ -2259,7 +2358,7 @@ export function ThreadComposer({
onClick={(e) => setCursorPosition(e.currentTarget.selectionStart ?? e.currentTarget.value.length)}
onPaste={onPaste}
rows={1}
placeholder={resolvedPlaceholder}
placeholder={sessionDragPreview ? "" : resolvedPlaceholder}
disabled={disabled}
aria-label={t("thread.composer.inputAria")}
className={cn(
@ -2646,11 +2745,14 @@ function ComposerCliMentionOverlay({
segments,
isHero,
className,
ghostRange,
}: {
segments: CapabilityMentionSegment[];
isHero: boolean;
className: string;
ghostRange?: { start: number; end: number } | null;
}) {
let offset = 0;
return (
<div
aria-hidden
@ -2660,16 +2762,24 @@ function ComposerCliMentionOverlay({
)}
>
{segments.map((segment, index) => {
const start = offset;
offset += segment.text.length;
if (segment.kind === "text") {
return <span key={`text-${index}`}>{segment.text}</span>;
}
const isGhost = ghostRange?.start === start && ghostRange.end === offset;
return (
<CapabilityMentionToken
<span
key={`${segment.kind}-${index}`}
segment={segment}
variant="composer"
isHero={isHero}
/>
data-testid={isGhost ? "composer-session-drag-preview" : undefined}
className={cn(isGhost && "opacity-45 transition-opacity duration-100")}
>
<CapabilityMentionToken
segment={segment}
variant="composer"
isHero={isHero}
/>
</span>
);
})}
</div>

View File

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

View File

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

View File

@ -1619,9 +1619,21 @@ describe("ThreadComposer", () => {
fireEvent.dragEnter(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 });
expect(input).toHaveValue("Compare @收费设计 notes");
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
expect(screen.getByTestId("composer-session-mention-收费设计"))
.toHaveTextContent("@收费设计");