From e15f7957a66ba20c02d03967055a0689aedc86ab Mon Sep 17 00:00:00 2001 From: Flyneen <48707833+Flyneen@users.noreply.github.com> Date: Sat, 24 Oct 2026 12:35:04 +0800 Subject: [PATCH] fix(sidebar): keep pinned conversations in the Recent list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pinning a conversation removed it from Recent entirely: the selector skipped every row with a `pinned_at`. Pinning is a bookmark that lifts a row into the top section, not a statement that it stopped being recent, so the row vanished from the one list the user scans by time. Recent now includes pinned rows, ordered with everything else by last activity. A pinned row also bypasses the "Show completed" and closed-folder gates, matching the Pinned section's own reachability rule — the two sections must never disagree about a row the user deliberately kept. The duplicated row is the existing, intended shape: a conversation already appears under its folder (or in Chat) and again in Recent. The Pinned section is one more copy, and the `recent-` row-key prefix already keeps the two React keys distinct, so `flatIndexOfConversation` still prefers the canonical row. Agent: opencode Session-Id: 45868-2cf53768 Working-Dir: D:\dev\codeg-recent-sort Generated-By: AI-assisted --- .../sidebar-conversation-grouping.test.ts | 40 +++++++++++++++++-- .../sidebar-conversation-grouping.ts | 23 ++++++++--- .../sidebar-conversation-list.test.tsx | 27 ++++++++++++- .../sidebar-conversation-list.tsx | 6 ++- 4 files changed, 83 insertions(+), 13 deletions(-) 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(