Skip to content

Commit e75835e

Browse files
authored
📌 feat: Unified Pinned Section With Sidebar Drag Interactions (#15057)
* feat: unified pinned section with drag interactions and sidebar polish - Pinned chats and favorite models/agents/specs render as one interleaved, reorderable list persisted via users.pinnedOrder - Drag conversations onto project rows to file them, onto Chats to unfile, and onto the Pinned section to pin (empty section materializes as a drop zone during a conversation drag) - Row-level unpin button on pinned chats matching the favorites' button, and the row action menu shares the same hover/focus treatment - Agent Marketplace entry moved under the New Chat button in the side rail - Bookmarks trigger sized to the search bar, search focus no longer swaps its border color, tag filtering shows a loading state, and opening a project shows row-shaped skeletons * fix: add pinnedOrder to IUser type and sort imports * fix: pinned drag threshold, unpin focus target, and marketplace link * fix: surface failed pinned reorders and skip no-op order writes * fix: address review findings on unified pinned section Move the pinned-order endpoints into packages/api as TypeScript handlers and route their URLs through the data-provider endpoint registry. Raise the entry and key caps so a valid model favorite and an unbounded pin count can both be persisted. Keep the marketplace entry reachable from the mobile drawer, which is the only sidebar surface on small screens. Merge a reorder into the stored order while a bookmark filter hides part of the list, instead of dropping the hidden keys. Add Alt+Arrow keyboard reordering with a live-region announcement, restore the focus handoff when a favorite is removed, and hold the local arrangement until the stored order catches up. Serialize overlapping order writes and discard the optimistic value when the first one fails. Derive the tag-filter loading state from isPreviousData rather than a flag that could stick on. * fix: release drag while renaming and dedupe repeated favorite keys A draggable ancestor swallows drag-select inside the rename input, so both the row's own drag source and the pinned wrapper's release while a title is being edited. The favorites payload validator accepts a repeated entry, and two rows keyed the same would collide in React, point every drag at the first copy, and fail the order endpoint's uniqueness check. Build the pinned list keyed-unique. * fix: address PR review bot findings Make the model favorite key injective. Joining endpoint and model on '::' collided for components containing the delimiter, so two distinct favorites keyed the same and the dedupe hid one; the endpoint is now length-prefixed. Replace the pinned-order entry cap with a total size guard. Pinning has no membership cap and the sidebar query drains every cursor, so any count limit rejects a legitimate list rather than bounding anything; what needs bounding is the user document. Let only the newest order write touch the cache. An earlier write failing rolled back over a newer arrangement that was still in flight, leaving the UI on a stale order while the server held the current one. A confirmed write now takes the server's answer, and a failed newest write discards the value that was never accepted. Persist the favorites array only once the order write succeeds, so a rejected write cannot leave the two stores disagreeing with the rolled-back display. * fix: address second round of PR review bot findings Read favorites membership at write time rather than capturing it at drag end. A favorite added or removed while the order request was in flight was undone by writing the older list back; only the relative order now comes from the drag. Move the pinned-order queue to module scope. The sidebar unmounts whole sections while a search is active, so a component-local ref let a remounted hook start a second queue alongside a request that was still running. Serialize project assignment per conversation. Every drop target owns its own mutation instance and the write is an unconditional update, so two quick drops let whichever request reached the database last decide the project. Read the pinned-order request body null-safely: destructuring a nullish body threw past the handler into Express's generic error path instead of returning the intended 400. * fix: address third round of PR review bot findings Drop the secondary favorites write. No consumer of the favorites array reads its order, so mirroring the arrangement there bought nothing while racing the membership mutations that share that whole-array endpoint; pinnedOrder is the single source of ordering. Always merge the committed order into the stored one. The visible list is not always the whole list, since a bookmark filter hides rows and the pinned query publishes partial results while draining its cursor, and replacing wholesale dropped every key it could not see. Release the local arrangement when the write settles instead of when the stored order matches it key for key. A pin arriving mid-request made that equality unreachable, stranding the snapshot and hiding the new row until remount. Abandon a queued pinned-order write if the signed-in user changed, so a deferred request cannot write one account's order into another's document. * fix: address fourth round of PR review bot findings Guard the pinned-order completion callbacks on the signed-in account. The pre-send check only covers a write that has not left yet; a response arriving after a session change would publish one account's order into the next one's cache, which this query never refetches on its own. Commit a reorder only when the pinned rows handled the drop. Dragging a pin onto a project row or into Chats sweeps the pointer across sibling rows, whose hover handlers shift the order, so a filing action was also saving a move the user never asked for. * fix: address fifth round of PR review bot findings Deselect pinnedOrder at schema level. It is display-only and allowed to grow large, but every authentication request loads the user document, so it was adding its whole payload to paths that never read it. Refuse to reorder until the stored order has loaded. Merging against an absent order and then cancelling its fetch posted only the visible keys and discarded saved positions for good. Prune keys only once the visible list is known to be the whole list. Merging unconditionally grew the array until the size guard rejected every write, so completeness now gates compaction rather than the filter flag alone. Track every renaming row, not just the most recent. RenameForm has no blur cancellation, so a second form can open while the first is still mounted and the single key reconnected the first row's drag source under its open input. Judge drop eligibility by the queued destination. A drag item carries the project its row had when the drag began, so re-dropping a chat back where it started while an assignment was queued read as a no-op. Take mutation ownership from the user atom AuthContext fills on login rather than the user query, which is still empty in that window, and never treat an unknown owner as a match. Restore the pinned section's 30vh cap. Matching the Projects section's 42vh put 84vh of non-shrinking content above Chats and starved it on short viewports. * fix: address sixth round of PR review bot findings Gate reordering and pruning on successful fetches rather than attempted ones. A failed pinned-order GET still reports as fetched, and a failed later page of the pinned drain still publishes partial data and stops fetching, so both gates were opening on exactly the states they were meant to exclude. Scope the post-unpin focus handoff to this section by its own marker. The whole conversations pane is a labelled region too, and the project lists inside it render the same row, where unpinning only clears the flag and the row stays put, so focus was being moved off a row that never went away. Clear the account tracker when the mutation hook unmounts. The sidebar drops that section during a search, leaving a stale non-null id that also shadowed the cache fallback, so a deferred write could pass its owner check after a session change. * fix: gate pruning on a delivered favorites list A favorites fetch that exhausted its retries leaves isLoading false with the list empty, so an otherwise complete pinned load enabled pruning and dropped every favorite key from the stored order. useFavorites now exposes the query's success state and the pruning gate requires it, matching the pinned-order and pinned-membership sources. * fix: address eighth round of PR review bot findings Identify queued assignments by token rather than by destination. Comparing destination values cannot tell two drops onto the same project apart, so a B, A, B run let the first B's cleanup discard an entry the still-queued later drops owned, and the next drop was then judged against a stale project. Report the rename ending when a row unmounts. A pinned row removed mid-rename, for instance by unpinning the same chat from the project list that also renders it, left its key in the renaming set and came back permanently undraggable. * fix: address ninth round of PR review bot findings Stand the keyboard reorder down wherever the pointer drag does. Alt/Option plus an arrow moves the caret word-wise inside a text field, so the shortcut was moving and saving the whole row out from under an open rename input. Hold a settled assignment destination until a drag item shows the new project. The mutation's cache invalidations are not awaited, so a row still reported its old project for a moment after the write succeeded and a quick drag back to where it started read as a no-op. * fix: address tenth round of PR review bot findings Serialize project assignment inside the mutation instead of at one caller. The drag targets, the row menu and the project dialog each hold their own instance, so a queued drop could still overwrite a newer menu write; moving the queue down covers every surface and lets the drag helper drop its own. Tie the live snapshot's release to the arrangement that created it. An older write settling mid-drag cleared the newer arrangement, snapping the rows back to a stale order while the drag still held its own, so releasing saved an order the user could no longer see. * fix: address eleventh round of PR review bot findings Move assignment destination tracking next to the queue that owns it. Keeping it in the drag helper meant a menu or dialog move left the drag helper still reporting its own older destination, with no row left to observe that would ever clear it, so a later move back was rejected for good. Resolve a conversation's project from the write the mutation confirmed rather than from whichever list rendered the row. The same chat appears in the Pinned section and its expanded project, and those caches refresh independently, so observing one of them was never a sound way to decide the other had caught up. The conversation cache is written synchronously on success, which makes the lazy self-clearing and its settled flag unnecessary. * fix: cancel an in-flight conversation refetch before confirming an assignment Navigation invalidates the exact conversation query, so a refetch that read the old project can be in flight when an assignment resolves and land afterwards, reverting the move in the cache that now answers where a conversation lives. * fix: keep queued pinned-order writes attributable across an unmount Refuse a queued write only when a different account is positively established. Clearing the identity on unmount stopped stale attributions but stranded work that belonged to the same account, since the sidebar drops the section that owns this hook whenever a search is active, and it denied that work its rollback as well. Correct the cache from the request rather than from onError. The observer callbacks do not run once that section unmounts, so an order the server never accepted could survive on a query that refetches on nothing. * fix: publish the session identity from the user atom Queued pinned-order writes were attributed by a tracker that only the pinned section's hook updated, and the sidebar unmounts that section during a search. A retained value went stale across a sign-in, and login clears the user query before the next account's fetch resolves, so a write queued by the previous account could pass its own check and post on the new account's credentials. A Recoil atom effect publishes the identity instead: it runs wherever the atom is set, including sign-out, so attribution comes from the state itself rather than from a component that happens to be mounted. That makes a signed-out session foreign to every owner and lets the check be a strict comparison again, rather than a rule that had to guess when identity was merely unreadable. * fix: discard reorders that ended on a refused external target A drop the pinned rows did not handle usually means a reorder, but a project row or the Chats section refusing one reports the same, so dragging a chat onto the project it already belongs to was saving the incidental shift its pointer caused on the way out. The rows and those targets now report their hovers, so the last one under the pointer settles it, which also allows for a drag that strays over a project and returns to reorder after all. * fix: attribute queued writes to the credentials they will travel with Publishing the account from React state, whether an effect or a Recoil atom effect, always lagged the credential change: setUserContext installs the new Authorization header in one synchronous call, and a write queued by the previous account and sent in that gap read its own owner back and went out as the new one. A test against the atom effect showed onSet is deferred too, so the atom was no better than the effect it replaced. The header is the thing that decides who the server sees, so ownership is read from it. The id claim is compared rather than the token, since a silent refresh rotates the token for the same account and discarding that work would strand something the user is still entitled to. Leaves AuthContext and the user atom untouched. * fix: attribute a reorder to the account that started it onMutate awaits cancelQueries before the request is built, and the mutation function was reading the session again afterwards, so credentials turning over inside that gap sent one account's order as the next. The owner is now recorded in onMutate's first synchronous statement and carried on the order itself, and the optimistic write is skipped when the session moved on, since that cache is shared and showing an order to the wrong account is the same mistake as sending it. Record a pinned hover before the guards rather than after. Hovering the dragged row is a no-op for ordering but still means the pointer is inside the list, so a drag that wandered onto a project and returned to its own position was being discarded as a filing action. * fix: address a further round of PR review bot findings Mark a hover anywhere in the pinned section as internal. The header and the padding around the rows are part of the list, so a drag that wandered onto a project and came back to rest on one of them ran no row hover and was discarded as a filing action. Hold off pruning while a favorites write is unsettled. The earlier GET's success stands through an optimistic removal, so pruning then dropped the ordering key of a write that had not landed, and a failed one came back without its saved position. Declare the reorder shortcut on the elements that take focus. The section's written hint is skipped by anyone tabbing straight to a row, leaving the only non-pointer way to reorder undiscoverable. * fix: reach a real New Chat control, and let the shortcut prop through the memo The unpin fallback looked for a test id no component carries, so once the last pinned row unmounted focus fell to the document rather than to New Chat. It now tries the controls that exist, in sidebar order. The test that covered this had fabricated the missing id, so it proved nothing. Convo is memoized on conversation fields and isGenerating alone, so the shortcut appearing once the pinned order loaded never reached the element that announces it. The comparator now accounts for it. * fix: identify OpenID sessions, and see favorites writes from every hook With OpenID token reuse the bearer is the provider's own id_token, which names its subject as sub under an issuer and carries no LibreChat id, so every such session resolved to nobody and two different accounts compared equal. That is the case the ownership guard exists for, so it read as safe while being exactly wrong. Sessions are identified by whatever the credential offers now, and an owner that cannot be identified is foreign to everything including itself. Count favorites writes across the query client rather than from one observer. A favorite row runs its own useFavorites, so a removal started there was invisible to the pinned section and its ordering key could still be pruned. * fix: close the assignment cache gap, and let a failed drain be retried The assignment released its pending destination as soon as the request settled, while the cache write waited on an awaited cancellation in onSuccess. A drag started in that gap saw neither, fell back to the stale row and refused a valid drop. The write now happens in the request, before the entry is released, which also survives the owning component unmounting. Also clear the pagination guard when the Chats section collapses. A drain that exhausted its retries leaves the conversations array unchanged, so the guard barred every later attempt and the remaining chats stayed unreachable for the session. Reopening the section now tries once more. That guard came from #14860 rather than this branch, so it can be split out if preferred. * fix: compose the marketplace link, and keep focus on a surviving row The marketplace control was a raw link with restated appearance classes, and in restating them it left out the focus ring entirely, so keyboard focus on it was invisible. It composes through the shared button now, which carries the ring, the hover fill and the theme timing. Unpinning from a project list leaves the row in place but removes the pin badge that had focus, and the pinned-section successor search deliberately returns nothing there, so focus fell to the document. It moves to the row's own link. Reconcile the pinned order on window focus and reconnect. It is one shared per-user record, so a second tab would otherwise show a stale arrangement and its next reorder would post a full array undoing the other tab's. * fix: keep a rename from restoring a conversation's other fields A rename request carries only a title, but its response is the whole conversation as the server saw it, and writing all of that back restores the pre-request value of every other field. An assignment confirmed while the rename was in flight was therefore reverted in the cache that now answers which project a conversation belongs to, and a drag back to the original project read as a no-op. Only the title and its timestamp are taken from the response. Cancelling queries cannot help here, since this is another mutation's write rather than a fetch. * fix: compose the unpin badge through the shared row-action button The badge restated a reusable icon control's sizing, radius, hover and focus classes. The client package already had a row-action variant and an icon-xs size that match it exactly, so it uses those instead of its own copy. * fix: reconcile before reordering, and scope assignments to their session A reorder stayed enabled while the order's focus refetch was in flight, so returning to a tab could cancel that refetch and post the arrangement it was about to replace. Pruning had the matching gap: the pinned list keeps its data fresh for five minutes, so a tab whose order had reconciled could prune against membership that had not, dropping the position of a conversation another tab pinned. Both now wait for data that is settled and not stale. Assignments carry the account that started them, like the order writes already did. Conversation ids are per-user, so a queued one travelling with the next account's credentials would act on whatever that id names over there, and its answer would describe one account's conversation to another in a cache that is not scoped by user. The row's overflow trigger derives from the shared row-action variant too, so the two actions in a row stop keeping separate copies of the same recipe. * fix: reject foreign assignment results, and share the section header An assignment settling after a session change returned normally, so the success path still ran: the caller reported success and the response could be applied to the next account's conversation, since ids repeat across accounts. It rejects now, so nothing downstream treats it as a result. The collapsible section header existed as three identical copies, Chats, Projects and Pinned, and this branch added the third. It moves into the shared button as a section-header variant and all three compose from it. * fix: let favorites membership reconcile between tabs The favorites query disabled focus, reconnect and mount refetches, so a second tab kept its old list indefinitely. Its pruning gate waits for a fetch, and none was ever going to happen, so a reorder there could delete the saved position of a favorite the first tab had added. It reconciles on focus and reconnect now, like the order it is arranged alongside. * fix: hold reconciling refetches off while a write is in flight Enabling focus and reconnect refetches on the order and favorites queries let one start after onMutate's single cancellation, read the pre-write state and land after the write, putting the old value back. Both now decline those refetches while a write for that resource is outstanding, keeping the cross-tab reconciliation itself. The check asks the query client at the moment the event fires rather than reading useIsMutating: that hook only reaches the query's options on the next render, which is a race against the very event being gated. A test that dispatches focus mid-write fails without it. * fix: release abandoned assignments and gate pruning on current membership An assignment rejected before entering the try block kept its pending entry forever, so a later session read a destination that was never sent. The ownership check now sits inside the block that releases it, and it runs again after the conversation query cancellation resolves, since the session can turn over while that await is pending. Pruning the pinned order now requires membership at least as current as the order it is pruned against. The pinned query holds its data for five minutes while the order has no such window, so focusing a tab reconciled the order alone and the next reorder dropped the key of a conversation another tab had pinned in between. The assignment spec resets its mocks between tests and gives each test its own conversation id, so one test's unconsumed mock and leftover queue entries no longer decide the next test's result. * fix: address the sixth round of review findings on the pinned section The section header recipe asked for the shared button variant without a size, so CVA emitted the default `h-10 px-4` after it and the merge kept it, putting a 40px control in a 32px header row. The variant opts out of default sizing through a compound now, since a heading is sized by its own text. Restore the search field's focus border. Removing it left keyboard focus signalled only by a background change that hover already uses, so the field had no distinguishable focus state at all. An assignment can reach the database before a concurrent rename and still answer after it, carrying a conversation that predates the rename. Only the project field is merged into the cached conversation and published to the active one now, matching what the rename handler already does. An order refetch that starts mid drag no longer lets the drop post an array built from the order that refetch is about to replace: the arrangement is held and written once the fresh order lands. Reorder writes superseded by a newer arrangement are skipped rather than sent, so holding the shortcut costs two writes instead of one per intermediate position. The order and favorites queries reconcile on mount as well, since a sidebar search unmounts the section and a query with no observer never sees the focus event. * fix: preserve pinned order until favorites membership catches up * fix: reconcile pinned membership and preserve auth and focus state * fix: preserve reconciled keys during active pinned arrangements * style: sort pinned order regression imports * fix: reconcile favorite rollbacks before pinned-order pruning
1 parent 523e800 commit e75835e

55 files changed

Lines changed: 5184 additions & 1514 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/server/routes/settings.js

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
const express = require('express');
2-
const { createToolFavoritesHandlers } = require('@librechat/api');
2+
const { CacheKeys } = require('librechat-data-provider');
3+
const {
4+
createToolFavoritesHandlers,
5+
createPinnedOrderHandlers,
6+
invalidateCachedAuthUserDoc,
7+
} = require('@librechat/api');
38
const {
49
updateFavoritesController,
510
getFavoritesController,
@@ -9,7 +14,14 @@ const {
914
updateSkillStatesController,
1015
} = require('~/server/controllers/SkillStatesController');
1116
const { requireJwtAuth } = require('~/server/middleware');
12-
const { getToolFavorites, addToolFavorite, removeToolFavorite } = require('~/models');
17+
const { getLogStores } = require('~/cache');
18+
const {
19+
getToolFavorites,
20+
addToolFavorite,
21+
removeToolFavorite,
22+
getUserById,
23+
updateUser,
24+
} = require('~/models');
1325

1426
const router = express.Router();
1527

@@ -19,6 +31,14 @@ const toolFavorites = createToolFavoritesHandlers({
1931
removeToolFavorite,
2032
});
2133

34+
const authUserDocCacheStore = getLogStores(CacheKeys.AUTH_USER_DOC);
35+
const pinnedOrder = createPinnedOrderHandlers({
36+
getUserById,
37+
updateUser,
38+
invalidateCachedAuthUserDoc: (userId) =>
39+
invalidateCachedAuthUserDoc(authUserDocCacheStore, { userId }),
40+
});
41+
2242
router.get('/favorites/tools', requireJwtAuth, toolFavorites.listToolFavorites);
2343
router.put('/favorites/tools/:itemType/:itemId', requireJwtAuth, toolFavorites.addToolFavorite);
2444
router.delete(
@@ -28,6 +48,8 @@ router.delete(
2848
);
2949
router.get('/favorites', requireJwtAuth, getFavoritesController);
3050
router.post('/favorites', requireJwtAuth, updateFavoritesController);
51+
router.get('/pinned-order', requireJwtAuth, pinnedOrder.getPinnedOrder);
52+
router.post('/pinned-order', requireJwtAuth, pinnedOrder.updatePinnedOrder);
3153
router.get('/skills/active', requireJwtAuth, getSkillStatesController);
3254
router.post('/skills/active', requireJwtAuth, updateSkillStatesController);
3355

client/src/components/Conversations/Conversations.tsx

Lines changed: 60 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,20 @@
11
import { useMemo, memo, type FC, useCallback, useEffect, useRef } from 'react';
2+
import { useDrop } from 'react-dnd';
23
import throttle from 'lodash/throttle';
34
import { useRecoilValue } from 'recoil';
45
import { ChevronDown } from 'lucide-react';
5-
import { Spinner, useMediaQuery } from '@librechat/client';
66
import { List, CellMeasurer, CellMeasurerCache } from 'react-virtualized';
7+
import { Spinner, useMediaQuery, buttonVariants } from '@librechat/client';
78
import type { TConversation } from 'librechat-data-provider';
89
import type { ReactNode } from 'react';
10+
import type { ConversationDragItem } from './dnd';
911
import {
10-
useLocalize,
11-
TranslationKeys,
12-
useFavorites,
13-
useShowMarketplace,
14-
useElementSize,
15-
} from '~/hooks';
16-
import FavoritesList from '~/components/Nav/Favorites/FavoritesList';
12+
CONVERSATION_DRAG_TYPE,
13+
markExternalHover,
14+
useAssignDroppedConversation,
15+
useEffectiveProjectId,
16+
} from './dnd';
17+
import { useLocalize, TranslationKeys, useElementSize } from '~/hooks';
1718
import { groupConversationsByDate, cn } from '~/utils';
1819
import { useActiveJobs } from '~/data-provider';
1920
import Convo from './Convo';
@@ -39,7 +40,6 @@ interface ConversationsProps {
3940
isSearchLoading: boolean;
4041
isChatsExpanded: boolean;
4142
setIsChatsExpanded: (expanded: boolean) => void;
42-
showFavorites?: boolean;
4343
/** Actions for the Chats header, alongside the Projects header's own. */
4444
chatsHeaderTrailing?: ReactNode;
4545
}
@@ -94,17 +94,24 @@ interface ChatsHeaderProps {
9494
onToggle: () => void;
9595
/** Section-scoped actions, mirroring the Projects header. */
9696
trailing?: ReactNode;
97+
/** Drop-target affordance while a project conversation is dragged over the section. */
98+
highlight?: boolean;
9799
}
98100

99101
/** Collapsible header for the Chats section */
100-
const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle, trailing }) => {
102+
const ChatsHeader: FC<ChatsHeaderProps> = memo(({ isExpanded, onToggle, trailing, highlight }) => {
101103
const localize = useLocalize();
102104

103105
return (
104-
<div className="flex h-8 w-full items-center pr-2">
106+
<div
107+
className={cn(
108+
'flex h-8 w-full items-center pr-2',
109+
highlight && 'rounded-lg bg-surface-active-alt',
110+
)}
111+
>
105112
<button
106113
onClick={onToggle}
107-
className="group flex min-w-0 flex-1 items-center gap-1 rounded-lg px-1 py-2 text-xs font-bold text-text-secondary outline-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-text-primary"
114+
className={cn(buttonVariants({ variant: 'section-header' }), 'group min-w-0 flex-1')}
108115
type="button"
109116
aria-expanded={isExpanded}
110117
>
@@ -142,7 +149,6 @@ const DateLabel: FC<{ groupName: string; isFirst?: boolean }> = memo(({ groupNam
142149
DateLabel.displayName = 'DateLabel';
143150

144151
type FlattenedItem =
145-
| { type: 'favorites' }
146152
| { type: 'header'; groupName: string }
147153
| { type: 'convo'; convo: TConversation }
148154
| { type: 'loading' };
@@ -157,38 +163,44 @@ const Conversations: FC<ConversationsProps> = ({
157163
isSearchLoading,
158164
isChatsExpanded,
159165
setIsChatsExpanded,
160-
showFavorites = true,
161166
chatsHeaderTrailing,
162167
}) => {
163168
const localize = useLocalize();
164169
const search = useRecoilValue(store.search);
165-
const { favorites, isLoading: isFavoritesLoading } = useFavorites();
166170
const isSmallScreen = useMediaQuery('(max-width: 768px)');
171+
/* Dropping a project conversation on the Chats section files it back out of
172+
* its project. Root-list chats already live here, so they are rejected. */
173+
const assignDropped = useAssignDroppedConversation();
174+
const effectiveProjectId = useEffectiveProjectId();
175+
const chatsRegionRef = useRef<HTMLDivElement>(null);
176+
const [{ isDropOver, canDrop }, dropRef] = useDrop<
177+
ConversationDragItem,
178+
unknown,
179+
{ isDropOver: boolean; canDrop: boolean }
180+
>({
181+
accept: CONVERSATION_DRAG_TYPE,
182+
canDrop: (item) => effectiveProjectId(item) != null,
183+
/* Reported even when refused, so a root chat dropped back on Chats does not
184+
* save the shift its pointer caused on the way out of the pinned list. */
185+
hover: () => markExternalHover(),
186+
drop: (item) => assignDropped(item, null),
187+
collect: (monitor) => ({ isDropOver: monitor.isOver(), canDrop: monitor.canDrop() }),
188+
});
189+
dropRef(chatsRegionRef);
167190
const convoHeight = isSmallScreen ? 44 : 34;
168-
const showAgentMarketplace = useShowMarketplace();
169191
const {
170192
ref: listContainerRef,
171193
width: listWidth,
172194
height: listHeight,
173195
} = useElementSize<HTMLDivElement>();
174196

175-
const favoritesContentKeyRef = useRef('');
176-
177197
// Fetch active job IDs for showing generation indicators
178198
const { data: activeJobsData } = useActiveJobs();
179199
const activeJobIds = useMemo(
180200
() => new Set(activeJobsData?.activeJobIds ?? []),
181201
[activeJobsData?.activeJobIds],
182202
);
183203

184-
// Determine if FavoritesList will render content
185-
const shouldShowFavorites =
186-
showFavorites &&
187-
!search.query &&
188-
(isFavoritesLoading || favorites.length > 0 || showAgentMarketplace);
189-
190-
favoritesContentKeyRef.current = `${favorites.length}-${showAgentMarketplace ? 1 : 0}-${isFavoritesLoading ? 1 : 0}`;
191-
192204
const filteredConversations = useMemo(
193205
() => rawConversations.filter(Boolean) as TConversation[],
194206
[rawConversations],
@@ -205,6 +217,18 @@ const Conversations: FC<ConversationsProps> = ({
205217
conversations input actually changes; a failed fetchNextPage leaves
206218
the same array and must not loop. */
207219
const paginatedFromRef = useRef<Array<TConversation | null> | null>(null);
220+
221+
/* A drain that exhausted its retries leaves that array unchanged, so the
222+
guard above would bar every later attempt and the remaining chats would
223+
stay unreachable for the rest of the session. Collapsing the section is a
224+
deliberate act, so reopening it is allowed to try once more, which is a
225+
retry path rather than a loop. */
226+
useEffect(() => {
227+
if (!isChatsExpanded) {
228+
paginatedFromRef.current = null;
229+
}
230+
}, [isChatsExpanded]);
231+
208232
useEffect(() => {
209233
if (!isChatsExpanded || isLoading || isSearchLoading || groupedConversations.length > 0) {
210234
return;
@@ -225,11 +249,6 @@ const Conversations: FC<ConversationsProps> = ({
225249

226250
const flattenedItems = useMemo(() => {
227251
const items: FlattenedItem[] = [];
228-
// Only include favorites row if FavoritesList will render content
229-
if (shouldShowFavorites) {
230-
items.push({ type: 'favorites' });
231-
}
232-
233252
if (isChatsExpanded) {
234253
groupedConversations.forEach(([groupName, convos]) => {
235254
items.push({ type: 'header', groupName });
@@ -241,7 +260,7 @@ const Conversations: FC<ConversationsProps> = ({
241260
}
242261
}
243262
return items;
244-
}, [groupedConversations, isLoading, isChatsExpanded, shouldShowFavorites]);
263+
}, [groupedConversations, isLoading, isChatsExpanded]);
245264

246265
// Store flattenedItems in a ref for keyMapper to access without recreating cache
247266
const flattenedItemsRef = useRef(flattenedItems);
@@ -258,12 +277,8 @@ const Conversations: FC<ConversationsProps> = ({
258277
if (!item) {
259278
return `unknown-${index}`;
260279
}
261-
if (item.type === 'favorites') {
262-
return `favorites-${favoritesContentKeyRef.current}`;
263-
}
264280
if (item.type === 'header') {
265-
const firstHeaderIndex = flattenedItemsRef.current[0]?.type === 'favorites' ? 1 : 0;
266-
return `header-${item.groupName}-${index === firstHeaderIndex ? 'first' : 'sub'}`;
281+
return `header-${item.groupName}-${index === 0 ? 'first' : 'sub'}`;
267282
}
268283
if (item.type === 'convo') {
269284
return `convo-${item.convo.conversationId}`;
@@ -277,22 +292,6 @@ const Conversations: FC<ConversationsProps> = ({
277292
[convoHeight],
278293
);
279294

280-
const clearFavoritesCache = useCallback(() => {
281-
if (cache) {
282-
cache.clear(0, 0);
283-
if (containerRef.current && 'recomputeRowHeights' in containerRef.current) {
284-
containerRef.current.recomputeRowHeights(0);
285-
}
286-
}
287-
}, [cache, containerRef]);
288-
289-
useEffect(() => {
290-
const frameId = requestAnimationFrame(() => {
291-
clearFavoritesCache();
292-
});
293-
return () => cancelAnimationFrame(frameId);
294-
}, [favorites.length, isFavoritesLoading, showAgentMarketplace, clearFavoritesCache]);
295-
296295
useEffect(() => {
297296
const frameId = requestAnimationFrame(() => {
298297
cache.clearAll();
@@ -345,19 +344,10 @@ const Conversations: FC<ConversationsProps> = ({
345344
);
346345
}
347346

348-
if (item.type === 'favorites') {
349-
return (
350-
<MeasuredRow key={key} {...rowProps}>
351-
<FavoritesList isSmallScreen={isSmallScreen} toggleNav={toggleNav} />
352-
</MeasuredRow>
353-
);
354-
}
355-
356347
if (item.type === 'header') {
357-
const firstHeaderIndex = flattenedItems[0]?.type === 'favorites' ? 1 : 0;
358348
return (
359349
<MeasuredRow key={key} {...rowProps}>
360-
<DateLabel groupName={item.groupName} isFirst={index === firstHeaderIndex} />
350+
<DateLabel groupName={item.groupName} isFirst={index === 0} />
361351
</MeasuredRow>
362352
);
363353
}
@@ -371,14 +361,15 @@ const Conversations: FC<ConversationsProps> = ({
371361
retainView={moveToTop}
372362
toggleNav={toggleNav}
373363
isGenerating={isGenerating}
364+
draggable
374365
/>
375366
</MeasuredRow>
376367
);
377368
}
378369

379370
return null;
380371
},
381-
[cache, flattenedItems, moveToTop, toggleNav, isSmallScreen, activeJobIds],
372+
[cache, flattenedItems, moveToTop, toggleNav, activeJobIds],
382373
);
383374

384375
const getRowHeight = useCallback(
@@ -401,12 +392,16 @@ const Conversations: FC<ConversationsProps> = ({
401392
);
402393

403394
return (
404-
<div className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary">
395+
<div
396+
ref={chatsRegionRef}
397+
className="relative flex h-full min-h-0 flex-col pb-2 text-sm text-text-primary"
398+
>
405399
<div className="px-3">
406400
<ChatsHeader
407401
isExpanded={isChatsExpanded}
408402
onToggle={() => setIsChatsExpanded(!isChatsExpanded)}
409403
trailing={chatsHeaderTrailing}
404+
highlight={isDropOver && canDrop}
410405
/>
411406
</div>
412407
{isSearchLoading ? (

0 commit comments

Comments
 (0)