fix(webui): preserve session drag contracts

This commit is contained in:
Xubin Ren 2026-08-06 14:38:50 +08:00
parent f44a766f98
commit 9af6bb91c7
4 changed files with 40 additions and 3 deletions

View File

@ -221,7 +221,11 @@ export const ChatList = memo(function ChatList({
const targetIndex = reordered.indexOf(targetKey); const targetIndex = reordered.indexOf(targetKey);
if (targetIndex < 0) return; if (targetIndex < 0) return;
reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey); reordered.splice(targetIndex + (edge === "after" ? 1 : 0), 0, draggedSessionKey);
onReorderSessions(reordered); const groupedKeys = new Set(keys);
onReorderSessions([
...reordered,
...sessionOrder.filter((key) => !groupedKeys.has(key)),
]);
}; };
return ( return (

View File

@ -1730,9 +1730,8 @@ export function ThreadComposer({
const previewSessionDrop = useCallback((event: React.DragEvent) => { const previewSessionDrop = useCallback((event: React.DragEvent) => {
if (!hasDraggedSession(event.dataTransfer)) return false; if (!hasDraggedSession(event.dataTransfer)) return false;
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
if (disabled) { if (disabled) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null); setSessionDragPreview(null);
return true; return true;
} }
@ -1744,9 +1743,12 @@ export function ThreadComposer({
(candidate) => candidate.session_key === mention.session_key, (candidate) => candidate.session_key === mention.session_key,
); );
if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) { if (!mention || (!alreadySelected && activeSessionMentions.length >= SESSION_MENTIONS_LIMIT)) {
event.dataTransfer.dropEffect = "none";
setSessionDragPreview(null); setSessionDragPreview(null);
return true; return true;
} }
event.preventDefault();
event.dataTransfer.dropEffect = "copy";
const start = textareaRef.current?.selectionStart ?? value.length; const start = textareaRef.current?.selectionStart ?? value.length;
const end = textareaRef.current?.selectionEnd ?? start; const end = textareaRef.current?.selectionEnd ?? start;
setSessionDragPreview((current) => ( setSessionDragPreview((current) => (

View File

@ -88,6 +88,8 @@ describe("ChatList", () => {
session({ chatId: "alpha", title: "Alpha" }), session({ chatId: "alpha", title: "Alpha" }),
session({ chatId: "bravo", title: "Bravo" }), session({ chatId: "bravo", title: "Bravo" }),
session({ chatId: "charlie", title: "Charlie" }), session({ chatId: "charlie", title: "Charlie" }),
session({ chatId: "old-a", title: "Old A" }),
session({ chatId: "old-b", title: "Old B" }),
]; ];
const { rerender } = render( const { rerender } = render(
<ChatList <ChatList
@ -99,6 +101,8 @@ describe("ChatList", () => {
onRequestRename={vi.fn()} onRequestRename={vi.fn()}
onToggleArchive={vi.fn()} onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions} onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={sessions.map((item) => item.key)}
/>, />,
); );
const dataTransfer = { const dataTransfer = {
@ -117,6 +121,8 @@ describe("ChatList", () => {
"websocket:bravo", "websocket:bravo",
"websocket:charlie", "websocket:charlie",
"websocket:alpha", "websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]); ]);
rerender( rerender(
@ -129,10 +135,13 @@ describe("ChatList", () => {
onRequestRename={vi.fn()} onRequestRename={vi.fn()}
onToggleArchive={vi.fn()} onToggleArchive={vi.fn()}
onReorderSessions={onReorderSessions} onReorderSessions={onReorderSessions}
archivedKeys={["websocket:old-a", "websocket:old-b"]}
sessionOrder={[ sessionOrder={[
"websocket:bravo", "websocket:bravo",
"websocket:charlie", "websocket:charlie",
"websocket:alpha", "websocket:alpha",
"websocket:old-a",
"websocket:old-b",
]} ]}
sort="manual" sort="manual"
/>, />,

View File

@ -1647,6 +1647,28 @@ describe("ThreadComposer", () => {
}); });
}); });
it("rejects session drops that are unavailable to the composer", () => {
render(
<ThreadComposer
onSend={vi.fn()}
placeholder="Type your message..."
sessions={[]}
/>,
);
const input = screen.getByLabelText("Message input");
const dataTransfer = {
types: [SESSION_DRAG_TYPE],
effectAllowed: "copyMove",
dropEffect: "copy",
files: [],
getData: () => "websocket:current",
};
expect(fireEvent.dragEnter(input, { dataTransfer })).toBe(true);
expect(fireEvent.dragOver(input, { dataTransfer })).toBe(true);
expect(screen.queryByTestId("composer-session-drag-preview")).not.toBeInTheDocument();
});
it("disambiguates duplicate and capability-colliding session names", () => { it("disambiguates duplicate and capability-colliding session names", () => {
render( render(
<ThreadComposer <ThreadComposer