diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index b450d47433..708837478f 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -1502,7 +1502,7 @@ describe("selectRecentConversationsWithReuse", () => { expect(out).toEqual([]) }) - it("mixes folder and chat conversations, newest first, excluding pinned", () => { + it("keeps pinned conversations, newest first, alongside unpinned ones", () => { const folderConv = conv(1, 10) const chatConv = conv(2, 99, { kind: "chat" }) // higher id → later created_at const pinned = conv(3, 10, { pinned_at: new Date(5000).toISOString() }) @@ -1512,8 +1512,42 @@ describe("selectRecentConversationsWithReuse", () => { open, [] ) - // Pinned lives in its own section; the other two interleave by recency. - expect(out.map((c) => c.id)).toEqual([2, 1]) + // Pinning is a bookmark, not a retirement: the row stays in the activity + // list (its Recent copy) as well as the Pinned section, so a user who pins + // something does not lose it from the one list they scan by time. + expect(out.map((c) => c.id)).toEqual([3, 2, 1]) + }) + + it("admits a pinned conversation even when its folder is closed", () => { + // The Pinned section ignores folder reachability (a pin is an explicit + // "keep this handy"), so Recent must agree rather than disagree about the + // row the user deliberately kept. + const pinnedInClosedFolder = conv(1, 77, { + pinned_at: new Date(5000).toISOString(), + }) + const unpinnedInClosedFolder = conv(2, 77) + const out = selectRecentConversationsWithReuse( + [pinnedInClosedFolder, unpinnedInClosedFolder], + true, + open, + [] + ) + expect(out.map((c) => c.id)).toEqual([1]) + }) + + it("keeps a pinned conversation visible even when completed ones are hidden", () => { + const pinnedDone = conv(1, 10, { + status: "completed", + pinned_at: new Date(5000).toISOString(), + }) + const unpinnedDone = conv(2, 10, { status: "completed" }) + const out = selectRecentConversationsWithReuse( + [pinnedDone, unpinnedDone], + false, + open, + [] + ) + expect(out.map((c) => c.id)).toEqual([1]) }) it("drops folder conversations whose folder is closed, but never chats", () => { diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 4b6832b9f6..bb633ac9a3 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -317,9 +317,15 @@ export function selectChatConversationsWithReuse( * in {@link selectPinnedWithReuse}. * * Deliberate inclusions / exclusions: - * - Pinned conversations are excluded. They already have a dedicated top - * section, and a Recent copy would be a second row for the same conversation - * two sections apart. + * - Pinned conversations ARE included. Pinning is a bookmark that lifts a row + * into the top section; it is not a statement that the conversation stopped + * being recent. Dropping it here meant pinning a row silently removed it from + * the one list the user scans by time. So it stays in Recent and shows twice + * (Pinned + Recent), exactly like a conversation that appears under its folder + * and in Recent. + * - A pinned conversation is admitted wherever it lives, closed folder or not, + * matching the Pinned section's own reachability rule: the two sections must + * never disagree about a row the user explicitly kept. * - Completed ones follow `showCompleted`, like every other section. * - A folder conversation is included only when its folder is OPEN * (`openFolderIds`). `list_all_conversations` returns rows for every @@ -351,10 +357,15 @@ export function selectRecentConversationsWithReuse( ): DbConversationSummary[] { const next: DbConversationSummary[] = [] for (const conv of conversations) { - if (conv.pinned_at != null) continue - if (!showCompleted && conv.status === "completed") continue const isChat = conv.kind === "chat" - if (!isChat && !openFolderIds.has(conv.folder_id)) continue + // Pinned rows bypass both gates below, exactly as the Pinned section does + // (it ignores "Show completed" and closed folders too): pinning is an + // explicit "keep this handy" override, and the two sections must never + // disagree about a row the user deliberately kept. + if (conv.pinned_at == null) { + if (!showCompleted && conv.status === "completed") continue + if (!isChat && !openFolderIds.has(conv.folder_id)) continue + } // The user's "just chats" / "just folder sessions" narrowing. Same split // the Chat and Folders sections use, so the two views always agree. if (filter === "chats" && !isChat) continue diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index 61567e91db..cbdd3f5ca8 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1197,6 +1197,29 @@ describe("SidebarConversationList — Recent section", () => { ).toHaveLength(2) }) + it("keeps a pinned conversation in Recent as well as in Pinned", () => { + // Pinning used to remove the row from Recent outright, so bookmarking + // something made it vanish from the one list the user scans by time. + // The collapse test above persists `{recent: true}`; left in place the + // section renders collapsed and contributes no row at all. + localStorage.clear() + useAppWorkspaceStore.setState({ + conversations: [ + conv(11, 1, { pinned_at: new Date(FIXED).toISOString() }), + ], + }) + render(recentTree(true)) + + const cards = Array.from( + document.querySelectorAll("[data-conversation-id]") + ) + expect(cards).toHaveLength(2) + const text = document.body.textContent ?? "" + // One row under Pinned, one under Recent — the section headers bracket them. + expect(text.indexOf("Pinned")).toBeLessThan(text.indexOf("conv-11")) + expect(text.indexOf(RECENT)).toBeLessThan(text.lastIndexOf("conv-11")) + }) + it("orders by last activity even though the sidebar sorts by creation time", () => { // The list under test is `sortMode="created"`. conv-11 was created first // but touched last; conv-12 is newer but idle. A section called "Recent" @@ -1586,7 +1609,7 @@ describe("SidebarConversationList — expand / collapse all", () => { allFolders: folders, conversations: [ // conv-11 is folder-bound (Folders + Recent), conv-12 is folderless - // chat mode (Chat + Recent only), conv-14 is pinned (Pinned only). + // chat mode (Chat + Recent only), conv-14 is pinned (Pinned + Recent). conv(11, 1), conv(12, 99, { kind: "chat" }), conv(14, 1, { pinned_at: new Date(FIXED).toISOString() }), @@ -1609,7 +1632,7 @@ describe("SidebarConversationList — expand / collapse all", () => { // Not just the header state — every row is actually gone. The three // conversations cover the three ways a row can reach the list: conv-11 via // a folder group, conv-12 (folderless chat mode) via Chat + Recent, conv-14 - // via Pinned. What is left is four headers and nothing under them. + // via Pinned + Recent. What is left is four headers and nothing under them. for (const title of ["conv-11", "conv-12", "conv-14"]) expect(document.body.textContent).not.toContain(title) expect(document.querySelectorAll("[data-conversation-id]")).toHaveLength(0) diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index eb59181ac2..397b7872e6 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -1412,8 +1412,10 @@ export function SidebarConversationList({ // closing a folder also removes its sessions here. Always ordered by // `updated_at`, never by the sidebar's created/updated `sortMode`: "Recent" // means last activity, and a session created long ago but used this morning - // belongs at the top. Reference reuse keeps an unrelated status event from - // rebuilding it and defeating the section's card memos. + // belongs at the top. Pinned rows stay in this list too (they are bookmarked, + // not retired), which is why the same conversation can show under Pinned and + // here. Reference reuse keeps an unrelated status event from rebuilding it and + // defeating the section's card memos. const recentConvsRef = useRef([]) const recentConversations = useMemo(() => { const next = selectRecentConversationsWithReuse(