feat(webui): allow dragging topics into groups

This commit is contained in:
Xubin Ren
2026-08-14 19:55:25 +09:00
parent 4d18dd2c30
commit cdf225cb89
2 changed files with 156 additions and 2 deletions
+73 -2
View File
@@ -63,7 +63,12 @@ import {
type ChatGroupLabels, type ChatGroupLabels,
} from "@/lib/chat-groups"; } from "@/lib/chat-groups";
import { deriveTemporaryChatTitle } from "@/lib/temporary-chat"; import { deriveTemporaryChatTitle } from "@/lib/temporary-chat";
import { clearDraggedSession, writeDraggedSession } from "@/lib/session-drag"; import {
clearDraggedSession,
hasDraggedSession,
readDraggedSession,
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";
@@ -162,6 +167,17 @@ export interface SidebarDeleteItem {
label: string; label: string;
} }
function droppablePaneKey(
dataTransfer: DataTransfer,
group: SidebarPaneGroup,
): string | null {
if (group.panes.length >= MAX_WORKBENCH_PANES
|| !hasDraggedSession(dataTransfer)) return null;
const paneKey = readDraggedSession(dataTransfer);
if (!paneKey || group.panes.some((pane) => pane.key === paneKey)) return null;
return paneKey;
}
interface ChatListProps { interface ChatListProps {
sessions: ChatSummary[]; sessions: ChatSummary[];
temporarySessions?: ChatSummary[]; temporarySessions?: ChatSummary[];
@@ -256,11 +272,22 @@ export const ChatList = memo(function ChatList({
const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>( const [collapsedPaneGroups, setCollapsedPaneGroups] = useState<Set<string>>(
readCollapsedPaneGroups, readCollapsedPaneGroups,
); );
const [paneDropTarget, setPaneDropTarget] = useState<string | null>(null);
const [deleteSelectionMode, setDeleteSelectionMode] = useState(false); const [deleteSelectionMode, setDeleteSelectionMode] = useState(false);
const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null); const [openActionMenuId, setOpenActionMenuId] = useState<string | null>(null);
const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>( const [selectedDeleteKeys, setSelectedDeleteKeys] = useState<Set<string>>(
() => new Set(), () => new Set(),
); );
useEffect(() => {
const clearPaneDropTarget = () => setPaneDropTarget(null);
window.addEventListener("dragend", clearPaneDropTarget);
window.addEventListener("drop", clearPaneDropTarget);
return () => {
window.removeEventListener("dragend", clearPaneDropTarget);
window.removeEventListener("drop", clearPaneDropTarget);
};
}, []);
const deleteItemsByKey = useMemo(() => { const deleteItemsByKey = useMemo(() => {
const items = new Map<string, SidebarDeleteItem>(); const items = new Map<string, SidebarDeleteItem>();
for (const group of Object.values(paneGroups)) { for (const group of Object.values(paneGroups)) {
@@ -633,15 +660,59 @@ export const ChatList = memo(function ChatList({
}} }}
data-sidebar-tab-group="true" data-sidebar-tab-group="true"
data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined} data-pane-group-collapsed={paneGroupCollapsed ? "true" : undefined}
data-pane-drop-target={
paneDropTarget === resolvedPaneGroup.tabKey ? "true" : undefined
}
className="relative my-1.5 min-w-0" className="relative my-1.5 min-w-0"
> >
<div <div
data-workbench-tab-surface data-workbench-tab-surface
onDragEnter={(event) => {
const paneKey = onAttachPane && !deleteSelectionMode
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
: null;
if (!paneKey) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setPaneDropTarget(resolvedPaneGroup.tabKey);
}}
onDragOver={(event) => {
const paneKey = onAttachPane && !deleteSelectionMode
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
: null;
if (!paneKey) return;
event.preventDefault();
event.stopPropagation();
event.dataTransfer.dropEffect = "move";
setPaneDropTarget(resolvedPaneGroup.tabKey);
}}
onDragLeave={(event) => {
const nextTarget = event.relatedTarget;
if (nextTarget instanceof Node
&& event.currentTarget.contains(nextTarget)) return;
setPaneDropTarget((current) => (
current === resolvedPaneGroup.tabKey ? null : current
));
}}
onDrop={(event) => {
const paneKey = onAttachPane && !deleteSelectionMode
? droppablePaneKey(event.dataTransfer, resolvedPaneGroup)
: null;
if (!paneKey || !onAttachPane) return;
event.preventDefault();
event.stopPropagation();
setPaneDropTarget(null);
clearDraggedSession();
onAttachPane(paneKey, resolvedPaneGroup.tabKey);
}}
className={cn( className={cn(
"min-w-0", "min-w-0 transition-[background-color,box-shadow]",
projectMode && "-ms-0.5", projectMode && "-ms-0.5",
deleteSelectionMode && (tabSelected || tabPartiallySelected) deleteSelectionMode && (tabSelected || tabPartiallySelected)
&& "ring-1 ring-inset ring-sidebar-foreground/25", && "ring-1 ring-inset ring-sidebar-foreground/25",
paneDropTarget === resolvedPaneGroup.tabKey
&& "bg-primary/[0.09] ring-2 ring-inset ring-primary/35 dark:bg-primary/[0.14]",
)} )}
> >
<WorkbenchTabHeader <WorkbenchTabHeader
+83
View File
@@ -238,6 +238,89 @@ describe("ChatList", () => {
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine"); expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:fine");
}); });
it("moves a dragged topic directly into a visible group", () => {
const onAttachPane = vi.fn();
render(
<ChatList
sessions={[
session({ chatId: "solo", title: "Solo topic" }),
session({ chatId: "target", title: "Target group" }),
session({ chatId: "full", title: "Full group" }),
]}
activeKey="websocket:target"
paneGroups={{
"websocket:solo": {
tabKey: "tab:solo",
title: "Solo topic",
activePaneKey: "websocket:solo",
visible: false,
panes: [{ key: "websocket:solo", chatId: "solo", title: "Solo topic" }],
},
"websocket:target": {
tabKey: "tab:target",
title: "Target group",
activePaneKey: "websocket:target",
visible: true,
panes: [{ key: "websocket:target", chatId: "target", title: "Target pane" }],
},
"websocket:full": {
tabKey: "tab:full",
title: "Full group",
activePaneKey: "websocket:full-1",
visible: true,
panes: [1, 2, 3, 4].map((index) => ({
key: `websocket:full-${index}`,
chatId: `full-${index}`,
title: `Full pane ${index}`,
})),
},
}}
onAttachPane={onAttachPane}
onSelect={vi.fn()}
onRequestDelete={vi.fn()}
onTogglePin={vi.fn()}
onRequestRename={vi.fn()}
onToggleArchive={vi.fn()}
/>,
);
const values = new Map<string, string>();
const dataTransfer = {
effectAllowed: "none",
dropEffect: "none",
setData: vi.fn((type: string, value: string) => values.set(type, value)),
getData: vi.fn((type: string) => values.get(type) ?? ""),
types: [SESSION_DRAG_TYPE],
} as unknown as DataTransfer;
const source = screen.getByRole("button", { name: "Solo topic" });
const targetGroup = screen.getByRole("button", { name: "Tab: Target group" })
.closest("[data-sidebar-tab-group]")!;
const targetSurface = targetGroup.querySelector("[data-workbench-tab-surface]")!;
const fullGroup = screen.getByRole("button", { name: "Tab: Full group" })
.closest("[data-sidebar-tab-group]")!;
const fullSurface = fullGroup.querySelector("[data-workbench-tab-surface]")!;
fireEvent.dragStart(source, { dataTransfer });
fireEvent.dragEnter(fullSurface, { dataTransfer });
fireEvent.dragOver(fullSurface, { dataTransfer });
fireEvent.drop(fullSurface, { dataTransfer });
expect(fullGroup).not.toHaveAttribute("data-pane-drop-target");
expect(onAttachPane).not.toHaveBeenCalled();
fireEvent.dragEnter(targetSurface, { dataTransfer });
fireEvent.dragOver(targetSurface, { dataTransfer });
expect(targetGroup).toHaveAttribute("data-pane-drop-target", "true");
expect(targetSurface).toHaveClass("ring-2", "ring-primary/35");
fireEvent.drop(targetSurface, { dataTransfer });
expect(onAttachPane).toHaveBeenCalledOnce();
expect(onAttachPane).toHaveBeenCalledWith("websocket:solo", "tab:target");
expect(targetGroup).not.toHaveAttribute("data-pane-drop-target");
});
it("shows every tab's pane membership in a sidebar tab group", async () => { it("shows every tab's pane membership in a sidebar tab group", async () => {
const onSelect = vi.fn(); const onSelect = vi.fn();
const onSelectPane = vi.fn(); const onSelectPane = vi.fn();