Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 37 additions & 3 deletions src/components/conversations/sidebar-conversation-grouping.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() })
Expand All @@ -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", () => {
Expand Down
23 changes: 17 additions & 6 deletions src/components/conversations/sidebar-conversation-grouping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
27 changes: 25 additions & 2 deletions src/components/conversations/sidebar-conversation-list.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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() }),
Expand All @@ -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)
Expand Down
6 changes: 4 additions & 2 deletions src/components/conversations/sidebar-conversation-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<DbConversationSummary[]>([])
const recentConversations = useMemo(() => {
const next = selectRecentConversationsWithReuse(
Expand Down
Loading