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:
Matt Vollmer
2026-03-27 07:57:23 -04:00
committed by GitHub
parent 83b2f85d63
commit 9e33035631
2 changed files with 52 additions and 27 deletions
+27 -1
View File
@@ -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>