mirror of
https://github.com/coder/coder.git
synced 2026-09-22 05:05:20 +08:00
fix: optimistic pinned reorder and post-drag click suppression (#23704)
## Summary
Fixes two issues with pinned chat drag-to-reorder in the agents sidebar:
1. **Missing optimistic reorder**: After dragging a pinned chat to a new
position, the sidebar flashed back to the old order while waiting for
the server response, then snapped to the new order. Now the react-query
cache is updated optimistically in `onMutate` so the reorder is visually
instant.
2. **Post-drag navigation on upward drag**: Dragging a pinned chat
**upward** caused unintended navigation to that chat on drop. The
browser synthesizes a click from the final `pointerup`, and the previous
suppression listener was attached too low in the DOM tree to reliably
intercept it after items reorder.
## Changes
### Optimistic cache reorder (`site/src/api/queries/chats.ts`)
`reorderPinnedChat.onMutate` now extracts all pinned chats from the
cache, reorders them to match the new position, and writes the updated
`pin_order` values back via `updateInfiniteChatsCache`. The server
response still reconciles on `onSettled`.
### Document-level click suppression (`AgentsSidebar.tsx`)
- Moved click suppression from the pinned container div to
`document.addEventListener('click', handler, true)`. This fires before
any element-level handlers regardless of DOM reordering during drag.
Scoped via `pinnedContainerRef.contains(target)` so it only affects
pinned rows.
- Replaced `recentDragRef` (boolean cleared by `setTimeout(100ms)`) with
`lastDragEndedAtRef` (`performance.now()` timestamp checked against a
300ms window). This eliminates the race where the timeout fires before
the synthetic click arrives.
---
PR generated with Coder Agents
This commit is contained in:
@@ -453,7 +453,13 @@ export const unpinChat = (queryClient: QueryClient) => ({
|
||||
export const reorderPinnedChat = (queryClient: QueryClient) => ({
|
||||
mutationFn: ({ chatId, pinOrder }: { chatId: string; pinOrder: number }) =>
|
||||
API.experimental.updateChat(chatId, { pin_order: pinOrder }),
|
||||
onMutate: async ({ chatId }: { chatId: string; pinOrder: number }) => {
|
||||
onMutate: async ({
|
||||
chatId,
|
||||
pinOrder,
|
||||
}: {
|
||||
chatId: string;
|
||||
pinOrder: number;
|
||||
}) => {
|
||||
await queryClient.cancelQueries({
|
||||
queryKey: chatsKey,
|
||||
predicate: isChatListQuery,
|
||||
@@ -462,6 +468,26 @@ export const reorderPinnedChat = (queryClient: QueryClient) => ({
|
||||
queryKey: chatKey(chatId),
|
||||
exact: true,
|
||||
});
|
||||
|
||||
// Optimistically reorder pinned chats in the cache so the
|
||||
// sidebar reflects the new order immediately without waiting
|
||||
// for the server round-trip.
|
||||
const allChats = readInfiniteChatsCache(queryClient) ?? [];
|
||||
const pinned = allChats
|
||||
.filter((c) => c.pin_order > 0)
|
||||
.sort((a, b) => a.pin_order - b.pin_order);
|
||||
const oldIdx = pinned.findIndex((c) => c.id === chatId);
|
||||
if (oldIdx !== -1) {
|
||||
const moved = pinned.splice(oldIdx, 1)[0];
|
||||
pinned.splice(pinOrder - 1, 0, moved);
|
||||
const newOrders = new Map(pinned.map((c, i) => [c.id, i + 1]));
|
||||
updateInfiniteChatsCache(queryClient, (chats) =>
|
||||
chats.map((c) => {
|
||||
const order = newOrders.get(c.id);
|
||||
return order !== undefined ? { ...c, pin_order: order } : c;
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
onSettled: async (
|
||||
_data: unknown,
|
||||
|
||||
@@ -672,8 +672,7 @@ const ChatTreeNode: FC<ChatTreeNodeProps> = ({ chat, isChildNode }) => {
|
||||
|
||||
const SortableChatTreeNode: FC<{
|
||||
chat: Chat;
|
||||
recentDragRef: React.RefObject<boolean>;
|
||||
}> = ({ chat, recentDragRef }) => {
|
||||
}> = ({ chat }) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
@@ -707,18 +706,6 @@ const SortableChatTreeNode: FC<{
|
||||
className={cn(isDragging && "opacity-50")}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClickCapture={(e) => {
|
||||
// After a drag, the browser synthesizes a click from
|
||||
// pointerup. dnd-kit's sensor calls stopPropagation
|
||||
// but never preventDefault, so the <a> tag inside
|
||||
// NavLink still fires its default navigation action.
|
||||
// Block it here in React's capture phase instead of
|
||||
// racing with a global document listener + rAF.
|
||||
if (recentDragRef.current) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ChatTreeNode chat={chat} isChildNode={false} />
|
||||
</div>
|
||||
@@ -807,11 +794,25 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
|
||||
const pinnedChatIds = sortedPinnedChats.map((chat) => chat.id);
|
||||
|
||||
// Ref flag set after drag ends. Checked by SortableChatTreeNode's
|
||||
// onClickCapture to block the synthetic click that the browser
|
||||
// fires from the final pointerup. Cleared after 100ms, which
|
||||
// comfortably exceeds dnd-kit's 50ms sensor cleanup window.
|
||||
const recentDragRef = useRef(false);
|
||||
const lastDragEndedAtRef = useRef(0);
|
||||
|
||||
const pinnedContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
useEffect(() => {
|
||||
const handler = (e: MouseEvent) => {
|
||||
const container = pinnedContainerRef.current;
|
||||
const target = e.target;
|
||||
if (
|
||||
container &&
|
||||
target instanceof Node &&
|
||||
container.contains(target) &&
|
||||
performance.now() - lastDragEndedAtRef.current < 300
|
||||
) {
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
document.addEventListener("click", handler, true);
|
||||
return () => document.removeEventListener("click", handler, true);
|
||||
}, []);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
@@ -828,11 +829,7 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
recentDragRef.current = true;
|
||||
setTimeout(() => {
|
||||
recentDragRef.current = false;
|
||||
}, 100);
|
||||
|
||||
lastDragEndedAtRef.current = performance.now();
|
||||
if (!over || active.id === over.id) return;
|
||||
const activeId = String(active.id);
|
||||
const overId = String(over.id);
|
||||
@@ -1087,12 +1084,14 @@ export const AgentsSidebar: FC<AgentsSidebarProps> = (props) => {
|
||||
items={pinnedChatIds}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div
|
||||
ref={pinnedContainerRef}
|
||||
className="flex flex-col gap-0.5"
|
||||
>
|
||||
{sortedPinnedChats.map((chat) => (
|
||||
<SortableChatTreeNode
|
||||
key={chat.id}
|
||||
chat={chat}
|
||||
recentDragRef={recentDragRef}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user