From b4adc69c89ed4d0a4661b39e9d5c8597408a4fca Mon Sep 17 00:00:00 2001 From: Flyneen <48707833+Flyneen@users.noreply.github.com> Date: Wed, 21 Oct 2026 17:07:10 +0800 Subject: [PATCH 1/6] feat(sidebar): order the Recent section by last activity The Recent section sorted by the sidebar's created/updated `sortMode` preference, which defaults to created_at. A section named "Recent" that ordered by creation time buried a session opened last month but used this morning under one created five minutes ago - the exact case the section exists to surface. Recent now always sorts by `updated_at` (last activity), independent of `sortMode`, and its rows show that same activity stamp so the label always explains the position above it. The other sections keep honouring the user's sort preference. `selectRecentConversationsWithReuse` loses its `sortMode` parameter, and the list drops it from the memo dependencies, so flipping the sort mode no longer rebuilds the Recent bucket either. Agent: opencode Session-Id: 45868-2cf53768 Working-Dir: D:\dev\codeg-recent-sort Generated-By: AI-assisted --- .../sidebar-conversation-grouping.test.ts | 68 ++++++------------- .../sidebar-conversation-grouping.ts | 24 ++++--- .../sidebar-conversation-list.test.tsx | 39 +++++++++++ .../sidebar-conversation-list.tsx | 20 ++++-- 4 files changed, 86 insertions(+), 65 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 9281b2b732..afd36ff4c0 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -1331,7 +1331,6 @@ describe("selectRecentConversationsWithReuse", () => { selectRecentConversationsWithReuse( [folderConv, chatConv], true, - "created", open, [], filter @@ -1350,7 +1349,6 @@ describe("selectRecentConversationsWithReuse", () => { selectRecentConversationsWithReuse( [fromCli, fromWindow, chat, closedCli], true, - "created", open, [], "cli" @@ -1362,7 +1360,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [conv(2, 77)], true, - "created", open, [], "folders" @@ -1377,7 +1374,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [folderConv, chatConv, pinned], true, - "created", open, [] ) @@ -1392,7 +1388,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [inOpen, inClosed, chatConv], true, - "created", open, [] ) @@ -1406,43 +1401,32 @@ describe("selectRecentConversationsWithReuse", () => { const done = conv(1, 10, { status: "completed" }) const active = conv(2, 10) expect( - selectRecentConversationsWithReuse( - [done, active], - false, - "created", - open, - [] - ).map((c) => c.id) + selectRecentConversationsWithReuse([done, active], false, open, []).map( + (c) => c.id + ) ).toEqual([2]) expect( - selectRecentConversationsWithReuse( - [done, active], - true, - "created", - open, - [] - ).map((c) => c.id) + selectRecentConversationsWithReuse([done, active], true, open, []).map( + (c) => c.id + ) ).toEqual([2, 1]) }) - it("sorts by the active sort mode so the order matches each card's label", () => { - const older = conv(1, 10, { updated_at: new Date(9_000_000).toISOString() }) - const newer = conv(2, 10, { updated_at: new Date(1_000).toISOString() }) - // Created-at descending puts the higher id first; updated-at flips them. - expect( - selectRecentConversationsWithReuse( - [older, newer], - true, - "created", - open, - [] - ).map((c) => c.id) - ).toEqual([2, 1]) + it("orders by last activity, not by creation time", () => { + // A session created long ago but touched this morning must outrank one + // created moments ago and left alone: that is what "Recent" means. + const longRunning = conv(1, 10, { + created_at: new Date(1_000).toISOString(), + updated_at: new Date(9_000_000).toISOString(), + }) + const brandNew = conv(2, 10, { + created_at: new Date(9_000_000).toISOString(), + updated_at: new Date(1_000).toISOString(), + }) expect( selectRecentConversationsWithReuse( - [older, newer], + [longRunning, brandNew], true, - "updated", open, [] ).map((c) => c.id) @@ -1451,20 +1435,8 @@ describe("selectRecentConversationsWithReuse", () => { it("returns the prev array when membership is referentially unchanged", () => { const a = conv(1, 10) - const first = selectRecentConversationsWithReuse( - [a], - true, - "created", - open, - [] - ) - const second = selectRecentConversationsWithReuse( - [a], - true, - "created", - open, - first - ) + const first = selectRecentConversationsWithReuse([a], true, open, []) + const second = selectRecentConversationsWithReuse([a], true, open, first) expect(second).toBe(first) }) }) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index f6935c9c9c..e5ff89e8ad 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -278,9 +278,9 @@ export function selectChatConversationsWithReuse( /** * Select the flat "Recent" bucket: every conversation the sidebar can reach, - * folder-bound and chat alike, newest first — the whole point of the section is - * that it does NOT distinguish the two. Reference reuse as in - * {@link selectPinnedWithReuse}. + * folder-bound and chat alike, most recently active first — the whole point + * of the section is that it does NOT distinguish the two. Reference reuse as + * in {@link selectPinnedWithReuse}. * * Deliberate inclusions / exclusions: * - Pinned conversations are excluded. They already have a dedicated top @@ -295,8 +295,12 @@ export function selectChatConversationsWithReuse( * screen (with no folder entry to theme or resolve them). Chat conversations * live in a hidden folder that is never in the open set, so they are admitted * by `kind` instead. - * - Sorted by `sortMode` (not always `updated_at`) so the row order agrees with - * the timestamp each card actually shows. + * - Sorted by the last activity (`updated_at`) ALWAYS, never by the sidebar's + * created/updated `sortMode` preference. A section called "Recent" that + * ordered by `created_at` would bury a session created last month but used + * this morning, which is exactly what the section exists to surface. The + * renderer shows the same timestamp on the row (see the list's `timeLabel`), + * so the order and the label can never disagree. * - `filter` optionally narrows the mix to chats only, folder sessions only, * or the conversations `codeg-cli` started; the default (`"all"`) is the * section's whole point. @@ -306,7 +310,6 @@ export function selectChatConversationsWithReuse( export function selectRecentConversationsWithReuse( conversations: readonly DbConversationSummary[], showCompleted: boolean, - sortMode: SidebarSortMode, openFolderIds: ReadonlySet, prev: DbConversationSummary[], filter: SidebarRecentFilter = "all" @@ -324,9 +327,7 @@ export function selectRecentConversationsWithReuse( if (filter === "cli" && conv.source !== "cli") continue next.push(conv) } - next.sort( - sortMode === "updated" ? compareByUpdatedAtDesc : compareByCreatedAtDesc - ) + next.sort(compareByUpdatedAtDesc) return arraysShallowEqual(prev, next) ? prev : next } @@ -1219,8 +1220,9 @@ export function buildRows(args: { chatConversations: readonly DbConversationSummary[] chatsExpanded: boolean /** The flat "Recent" bucket — every reachable conversation, folder-bound and - * chat alike (or just one kind, under the user's filter), newest first (see - * {@link selectRecentConversationsWithReuse}). Only read when `showRecent`. + * chat alike (or just one kind, under the user's filter), most recently + * active first (see {@link selectRecentConversationsWithReuse}). Only read + * when `showRecent`. * Optional — defaults to empty. */ recentConversations?: readonly DbConversationSummary[] /** Whether the Recent section's rows are shown (its own collapse toggle). diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index f8c5a33617..d6695ab461 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1197,6 +1197,45 @@ describe("SidebarConversationList — Recent section", () => { ).toHaveLength(2) }) + 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" + // must lead with conv-11 and show its activity stamp, not its birth date. + // Timestamps hang off the real clock: this suite runs on real timers. + const t0 = Date.now() + // The collapse test above persists `{recent: true}` into + // `workspace:sidebar-section-collapsed`, and the list hydrates from it. + localStorage.clear() + useAppWorkspaceStore.setState({ + conversations: [ + conv(11, 1, { + created_at: new Date(t0 - 5 * MINUTE).toISOString(), + updated_at: new Date(t0 - 30_000).toISOString(), + }), + conv(12, 1, { + created_at: new Date(t0 - MINUTE).toISOString(), + updated_at: new Date(t0 - 3 * MINUTE).toISOString(), + }), + ], + }) + render(recentTree(true)) + + // The Folders section emits the canonical copies first (creation order: + // conv-12 is newer); everything after them is the Recent duplicate, in + // activity order (conv-11 was touched last). + const rows = Array.from(document.querySelectorAll("[data-conv-key]")) + expect(rows.map((el) => el.textContent)).toEqual([ + expect.stringContaining("conv-12"), + expect.stringContaining("conv-11"), + expect.stringContaining("conv-11"), + expect.stringContaining("conv-12"), + ]) + // The Recent copy of conv-11 carries "now" (30s ago), not its creation + // label ("5m"), so the row's position is explained by what it shows. + expect(rows[2].textContent).toContain("conv-11") + expect(rows[2].textContent).toContain("now") + }) + describe("paging", () => { // `recentLimit` starts at RECENT_PAGE_SIZE and only ever grew, so a list // expanded a few pages deep stayed that way for the rest of the session. diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index 29ecf49668..c066315e5f 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -1407,22 +1407,24 @@ export function SidebarConversationList({ ) // Flat "Recent" bucket: every reachable conversation — folder-bound and chat - // alike — newest first, gated on the folder still being open so closing a - // folder also removes its sessions here. Reference reuse keeps an unrelated - // status event from rebuilding it and defeating the section's card memos. + // alike — most recently active first, gated on the folder still being open so + // 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. const recentConvsRef = useRef([]) const recentConversations = useMemo(() => { const next = selectRecentConversationsWithReuse( shownConversations, showCompleted, - sortMode, openFolderIds, recentConvsRef.current, recentFilter ) recentConvsRef.current = next return next - }, [shownConversations, showCompleted, sortMode, openFolderIds, recentFilter]) + }, [shownConversations, showCompleted, openFolderIds, recentFilter]) // Maps each open worktree child folder → its (open) root folder. A child is // only redirected when its parent is also open, so a worktree whose root was @@ -3183,8 +3185,14 @@ export function SidebarConversationList({ selectedConversation?.id === conv.id } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} + // Recent is ordered by last activity (see + // `selectRecentConversationsWithReuse`), so its rows must show the + // activity stamp even when the rest of the sidebar is sorted by + // creation time — the label always explains the position above it. timeLabel={formatRelative( - sortMode === "updated" ? conv.updated_at : conv.created_at, + row.recent || sortMode === "updated" + ? conv.updated_at + : conv.created_at, now )} onSelect={handleSelect} From b197a51707dff93d6ca9243ab7687039d6fedf11 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 21 Oct 2026 18:50:16 +0800 Subject: [PATCH 2/6] fix(sidebar): keep a Recent parent's sub-sessions on their creation label Recent's rows switched to their last-activity label, but the flag that marks a Recent copy also covers the expanded sub-sessions under it. Those keep the creation order they have under their folder, so under creation-time sorting they showed an age that did not explain their position, and a different one from their twin under the folder. The label rule moves into one helper, `conversationRowTimestamp`: Recent's own top-level rows read `updated_at`, every other row follows `sortMode`. The list test now pins every row's label, so a change that moved the Folders copies off their creation age fails it too. --- .../sidebar-conversation-grouping.test.ts | 63 +++++++++++++++++++ .../sidebar-conversation-grouping.ts | 28 ++++++++- .../sidebar-conversation-list.test.tsx | 17 +++-- .../sidebar-conversation-list.tsx | 15 ++--- 4 files changed, 101 insertions(+), 22 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index afd36ff4c0..6206a3fb78 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -5,6 +5,7 @@ import { buildOwnerHeaderIndex, buildRows, computeStickyState, + conversationRowTimestamp, flatIndexOfConversation, folderHeaderFlatIndices, formatRelative, @@ -21,6 +22,7 @@ import { selectRecentConversationsWithReuse, worktreeChildrenByParent, worktreeHeaderAlias, + type ConversationRow, type SidebarRow, } from "./sidebar-conversation-grouping" @@ -89,6 +91,67 @@ describe("formatRelative", () => { }) }) +describe("conversationRowTimestamp", () => { + // Created long ago, touched recently: the two stamps disagree, so every + // assertion pins which one a row's label reads. + const created = new Date(1_000).toISOString() + const updated = new Date(9_000_000).toISOString() + const parent = conv(1, 10, { + child_count: 1, + created_at: created, + updated_at: updated, + }) + const kid = conv(2, 10, { created_at: created, updated_at: updated }) + // The parent is listed under its folder and in Recent, expanded both times, + // so each conversation comes out twice: its canonical row, then its Recent + // copy. + const rows = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([[10, [parent]]]), + folderExpanded: { 10: true }, + folderTotalCounts: new Map([[10, 1]]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + recentConversations: [parent], + showRecent: true, + conversationExpanded: new Set([1]), + childrenByParent: new Map([[1, [kid]]]), + }).filter((r): r is ConversationRow => r.kind === "conversation") + const parents = rows.filter((r) => r.conversation.id === 1) + const kids = rows.filter((r) => r.conversation.id === 2) + + it("reads the last activity on Recent's own rows, whatever the sort mode", () => { + expect(parents.map((r) => r.recent ?? false)).toEqual([false, true]) + expect(conversationRowTimestamp(parents[1], "created")).toBe(updated) + expect(conversationRowTimestamp(parents[1], "updated")).toBe(updated) + }) + + it("follows the sort mode under the folder", () => { + expect(conversationRowTimestamp(parents[0], "created")).toBe(created) + expect(conversationRowTimestamp(parents[0], "updated")).toBe(updated) + }) + + it("labels a Recent parent's sub-sessions like their twins under the folder", () => { + // Sub-sessions are creation-ordered in both places, so the Recent copy + // must not switch to the activity stamp its parent reads. + expect(kids.map((r) => [r.depth, r.recent ?? false])).toEqual([ + [1, false], + [1, true], + ]) + expect(kids.map((r) => conversationRowTimestamp(r, "created"))).toEqual([ + created, + created, + ]) + expect(kids.map((r) => conversationRowTimestamp(r, "updated"))).toEqual([ + updated, + updated, + ]) + }) +}) + describe("groupByFolderWithReuse", () => { it("groups by folder and sorts each bucket by created-at descending", () => { const list = [conv(1, 10), conv(3, 10), conv(2, 20), conv(4, 10)] diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index e5ff89e8ad..dca6765e1f 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -118,6 +118,27 @@ export function formatRelative(iso: string, now: number): string { return `${y}y` } +/** + * The timestamp a conversation row's relative time label reads. + * + * The label has to explain where its row sits. The Recent section orders its + * rows by last activity whatever the sidebar's `sortMode` says (see + * {@link selectRecentConversationsWithReuse}), so they show `updated_at` even + * while the rest of the sidebar is sorted, and labelled, by creation time. + * Only the section's own top-level rows, though: the sub-sessions under an + * expanded Recent parent keep the creation order they have under their folder + * (see {@link mergeChildrenById}), so they follow `sortMode` and read the same + * as their twins there. + */ +export function conversationRowTimestamp( + row: ConversationRow, + sortMode: SidebarSortMode +): string { + const conv = row.conversation + if (row.recent && row.depth === 0) return conv.updated_at + return sortMode === "updated" ? conv.updated_at : conv.created_at +} + function arraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { if (a === b) return true if (a.length !== b.length) return false @@ -298,9 +319,10 @@ export function selectChatConversationsWithReuse( * - Sorted by the last activity (`updated_at`) ALWAYS, never by the sidebar's * created/updated `sortMode` preference. A section called "Recent" that * ordered by `created_at` would bury a session created last month but used - * this morning, which is exactly what the section exists to surface. The - * renderer shows the same timestamp on the row (see the list's `timeLabel`), - * so the order and the label can never disagree. + * this morning, which is exactly what the section exists to surface. Its + * rows' time labels read the same stamp (see + * {@link conversationRowTimestamp}), so the order and the label can never + * disagree. * - `filter` optionally narrows the mix to chats only, folder sessions only, * or the conversations `codeg-cli` started; the default (`"all"`) is the * section's whole point. diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index d6695ab461..d98ab897ae 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1222,18 +1222,17 @@ describe("SidebarConversationList — Recent section", () => { // The Folders section emits the canonical copies first (creation order: // conv-12 is newer); everything after them is the Recent duplicate, in - // activity order (conv-11 was touched last). + // activity order (conv-11 was touched last). Each row's text is its title + // followed by its age label: the Folders copies keep their creation age + // ("1m", "5m"), while the Recent copies read their last activity ("now" + // for 30s ago, "3m"), so each row's position is explained by what it shows. const rows = Array.from(document.querySelectorAll("[data-conv-key]")) expect(rows.map((el) => el.textContent)).toEqual([ - expect.stringContaining("conv-12"), - expect.stringContaining("conv-11"), - expect.stringContaining("conv-11"), - expect.stringContaining("conv-12"), + "conv-121m", + "conv-115m", + "conv-11now", + "conv-123m", ]) - // The Recent copy of conv-11 carries "now" (30s ago), not its creation - // label ("5m"), so the row's position is explained by what it shows. - expect(rows[2].textContent).toContain("conv-11") - expect(rows[2].textContent).toContain("now") }) describe("paging", () => { diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index c066315e5f..ce6a0faf0a 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -118,6 +118,7 @@ import { locateEntry, reconcileLayout, computeStickyState, + conversationRowTimestamp, flatIndexOfConversation, folderHeaderFlatIndices, formatRelative, @@ -3185,16 +3186,10 @@ export function SidebarConversationList({ selectedConversation?.id === conv.id } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} - // Recent is ordered by last activity (see - // `selectRecentConversationsWithReuse`), so its rows must show the - // activity stamp even when the rest of the sidebar is sorted by - // creation time — the label always explains the position above it. - timeLabel={formatRelative( - row.recent || sortMode === "updated" - ? conv.updated_at - : conv.created_at, - now - )} + // The stamp the row's section orders it by, so the label explains its + // position: Recent's own rows read their last activity even while the + // rest of the sidebar is sorted by creation time. + timeLabel={formatRelative(conversationRowTimestamp(row, sortMode), now)} onSelect={handleSelect} onDoubleClick={handleDoubleClick} onRename={handleRename} From adbea802d05dcd7ec484e9feb4142df596c72f21 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 21 Oct 2026 18:54:51 +0800 Subject: [PATCH 3/6] fix(sidebar): label Chat rows with the last activity they are sorted by The Chat section has always ordered its rows by `updated_at`, whatever the sort preference says, yet labelled them with the preference's stamp. Under the default creation-time sorting a chat started two days ago and used a moment ago sat on top reading "2d", above one reading "1h". `conversationRowTimestamp` now gives Chat's own rows their last activity, the same rule Recent follows. A pinned chat sits in Pinned, which is ordered by pin time, so it keeps the preference's stamp like every other pinned row. --- .../sidebar-conversation-grouping.test.ts | 34 ++++++++++ .../sidebar-conversation-grouping.ts | 32 ++++++---- .../sidebar-conversation-list.test.tsx | 62 +++++++++++++++++++ .../sidebar-conversation-list.tsx | 4 +- 4 files changed, 119 insertions(+), 13 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 6206a3fb78..f1beff0399 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -150,6 +150,40 @@ describe("conversationRowTimestamp", () => { updated, ]) }) + + it("reads the last activity on Chat rows, which are sorted by it", () => { + const chat = conv(3, 99, { + kind: "chat", + created_at: created, + updated_at: updated, + }) + // Pinned chats leave the Chat bucket for Pinned, which is ordered by pin + // time, so they keep the sort mode's stamp like every other pinned row. + const pinnedChat = conv(4, 99, { + kind: "chat", + created_at: created, + updated_at: updated, + pinned_at: updated, + }) + const chatRows = buildRows({ + pinned: [pinnedChat], + pinnedExpanded: true, + orderedFolderIds: [], + byFolder: new Map(), + folderExpanded: {}, + folderTotalCounts: new Map(), + foldersExpanded: true, + chatConversations: [chat], + chatsExpanded: true, + }).filter((r): r is ConversationRow => r.kind === "conversation") + expect(chatRows.map((r) => r.conversation.id)).toEqual([4, 3]) + expect(chatRows.map((r) => conversationRowTimestamp(r, "created"))).toEqual( + [created, updated] + ) + expect(chatRows.map((r) => conversationRowTimestamp(r, "updated"))).toEqual( + [updated, updated] + ) + }) }) describe("groupByFolderWithReuse", () => { diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index dca6765e1f..1948df9ac7 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -121,21 +121,29 @@ export function formatRelative(iso: string, now: number): string { /** * The timestamp a conversation row's relative time label reads. * - * The label has to explain where its row sits. The Recent section orders its - * rows by last activity whatever the sidebar's `sortMode` says (see - * {@link selectRecentConversationsWithReuse}), so they show `updated_at` even - * while the rest of the sidebar is sorted, and labelled, by creation time. - * Only the section's own top-level rows, though: the sub-sessions under an - * expanded Recent parent keep the creation order they have under their folder - * (see {@link mergeChildrenById}), so they follow `sortMode` and read the same - * as their twins there. + * The label has to explain where its row sits. Two sections order their rows + * by last activity whatever the sidebar's `sortMode` says: Recent (see + * {@link selectRecentConversationsWithReuse}) and Chat (see + * {@link selectChatConversationsWithReuse}). Their rows show `updated_at` even + * while the rest of the sidebar is sorted, and labelled, by creation time. A + * Chat row is recognised by what that bucket admits: a top-level chat-kind + * conversation that is not pinned (a pinned one sits in Pinned instead). + * + * Every other row follows `sortMode`. That covers the Folders rows, which are + * sorted by it; the Pinned rows, which are ordered by pin time, a stamp no + * label shows; and the sub-sessions under an expanded parent, which keep the + * creation order they have under their folder (see {@link mergeChildrenById}), + * so the copies under a Recent parent read the same as their twins there. */ export function conversationRowTimestamp( row: ConversationRow, sortMode: SidebarSortMode ): string { const conv = row.conversation - if (row.recent && row.depth === 0) return conv.updated_at + if (row.depth === 0) { + if (row.recent) return conv.updated_at + if (conv.kind === "chat" && conv.pinned_at == null) return conv.updated_at + } return sortMode === "updated" ? conv.updated_at : conv.created_at } @@ -272,8 +280,10 @@ export function selectPinnedWithReuse( /** * Select the folderless "chat mode" conversations (`kind === "chat"`) for the - * flat "Chat" sidebar section. Sorted most-recently-updated first, with - * reference reuse (same motivation as {@link selectPinnedWithReuse}). + * flat "Chat" sidebar section. Sorted most-recently-updated first whatever the + * sidebar's `sortMode` says, and labelled with that same stamp (see + * {@link conversationRowTimestamp}), with reference reuse (same motivation as + * {@link selectPinnedWithReuse}). * * Excludes pinned conversations (they surface in the Pinned section, an explicit * override) and — unless `showCompleted` — completed ones, matching how diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index d98ab897ae..382e8d32e8 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1477,6 +1477,68 @@ describe("SidebarConversationList — Recent section", () => { }) }) +describe("SidebarConversationList — time labels", () => { + beforeEach(() => { + // Section collapse and folder expansion persist across tests otherwise. + localStorage.clear() + const folders = [folder(1, "Repo")] + store.activeTabId = null + store.tabSpec = [] + useAppWorkspaceStore.setState({ folders, allFolders: folders }) + }) + + it("labels Chat rows with the last activity they are ordered by", () => { + // The list under test is `sortMode="created"`, and Chat orders by last + // activity regardless. Timestamps hang off the real clock: this suite runs + // on real timers, and the list's `now` is read at mount. + const t0 = Date.now() + const HOUR = 60 * MINUTE + useAppWorkspaceStore.setState({ + conversations: [ + // Started two days ago, used 30s ago: Chat lists it first. + conv(21, 99, { + kind: "chat", + created_at: new Date(t0 - 48 * HOUR).toISOString(), + updated_at: new Date(t0 - 30_000).toISOString(), + }), + conv(22, 99, { + kind: "chat", + created_at: new Date(t0 - HOUR).toISOString(), + updated_at: new Date(t0 - HOUR).toISOString(), + }), + // Pinned (ordered by pin time) and Folders (ordered by creation here) + // keep the creation age. + conv(23, 99, { + kind: "chat", + created_at: new Date(t0 - 3 * HOUR).toISOString(), + updated_at: new Date(t0 - 30_000).toISOString(), + pinned_at: new Date(t0 - 30_000).toISOString(), + }), + conv(24, 1, { + created_at: new Date(t0 - 5 * MINUTE).toISOString(), + updated_at: new Date(t0 - 30_000).toISOString(), + }), + ], + }) + render( + + + + ) + + // Pinned, Folders, then Chat; each row's text is its title followed by + // its age label. Chat reads "now" above "1h" (its order), not "2d" above + // "1h". + const rows = Array.from(document.querySelectorAll("[data-conv-key]")) + expect(rows.map((el) => el.textContent)).toEqual([ + "conv-233h", + "conv-245m", + "conv-21now", + "conv-221h", + ]) + }) +}) + describe("SidebarConversationList — expand / collapse all", () => { const SECTION_COLLAPSED_KEY = "workspace:sidebar-section-collapsed" const FOLDER_EXPANDED_KEY = "workspace:sidebar-folder-expanded" diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index ce6a0faf0a..a1143448f7 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -3187,8 +3187,8 @@ export function SidebarConversationList({ } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} // The stamp the row's section orders it by, so the label explains its - // position: Recent's own rows read their last activity even while the - // rest of the sidebar is sorted by creation time. + // position: Recent's and Chat's own rows read their last activity even + // while the rest of the sidebar is sorted by creation time. timeLabel={formatRelative(conversationRowTimestamp(row, sortMode), now)} onSelect={handleSelect} onDoubleClick={handleDoubleClick} From afc77f096369a03eaf2e6c7a08525b17b78b89a0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 21 Oct 2026 18:57:28 +0800 Subject: [PATCH 4/6] fix(sidebar): label sub-sessions with the creation time they are sorted by Sub-sessions are listed newest-spawned first under every parent, whatever the sort preference says, yet their label followed the preference. Under activity sorting a parent's sub-agents could read "30m", "48m", "10m" from top to bottom, ages that did not explain their order. `conversationRowTimestamp` now gives every sub-session its creation time. It recognises one by `parent_id`, as the card does, because a folder's own rows are indented to depth 1 too inside a folder group and under Show worktrees, and those still follow the preference. --- .../sidebar-conversation-grouping.test.ts | 46 +++++++++++++++++-- .../sidebar-conversation-grouping.ts | 37 ++++++++------- .../sidebar-conversation-list.tsx | 7 +-- 3 files changed, 66 insertions(+), 24 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index f1beff0399..96c895d89a 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -101,7 +101,11 @@ describe("conversationRowTimestamp", () => { created_at: created, updated_at: updated, }) - const kid = conv(2, 10, { created_at: created, updated_at: updated }) + const kid = conv(2, 10, { + parent_id: 1, + created_at: created, + updated_at: updated, + }) // The parent is listed under its folder and in Recent, expanded both times, // so each conversation comes out twice: its canonical row, then its Recent // copy. @@ -134,9 +138,10 @@ describe("conversationRowTimestamp", () => { expect(conversationRowTimestamp(parents[0], "updated")).toBe(updated) }) - it("labels a Recent parent's sub-sessions like their twins under the folder", () => { - // Sub-sessions are creation-ordered in both places, so the Recent copy - // must not switch to the activity stamp its parent reads. + it("reads the creation time on sub-sessions, which are sorted by it", () => { + // Creation-ordered under every parent and in either sort mode, so neither + // the activity stamp a Recent parent reads nor an activity sort moves + // their label off it, and the Recent copy matches its twin. expect(kids.map((r) => [r.depth, r.recent ?? false])).toEqual([ [1, false], [1, true], @@ -146,6 +151,39 @@ describe("conversationRowTimestamp", () => { created, ]) expect(kids.map((r) => conversationRowTimestamp(r, "updated"))).toEqual([ + created, + created, + ]) + }) + + it("tells a sub-session apart by its parent, not by its indent", () => { + // Under Show worktrees a repo's own rows and its worktree's rows both sit + // at depth 1, yet they are top-level conversations sorted by `sortMode`. + const rootConv = conv(5, 10, { created_at: created, updated_at: updated }) + const wtConv = conv(6, 11, { created_at: created, updated_at: updated }) + const nested = buildRows({ + pinned: [], + pinnedExpanded: true, + orderedFolderIds: [10], + byFolder: new Map([ + [10, [rootConv]], + [11, [wtConv]], + ]), + folderExpanded: {}, + folderTotalCounts: new Map([ + [10, 1], + [11, 1], + ]), + foldersExpanded: true, + chatConversations: [], + chatsExpanded: true, + containerChildren: new Map([[10, [11]]]), + }).filter((r): r is ConversationRow => r.kind === "conversation") + expect(nested.map((r) => [r.conversation.id, r.depth])).toEqual([ + [5, 1], + [6, 1], + ]) + expect(nested.map((r) => conversationRowTimestamp(r, "updated"))).toEqual([ updated, updated, ]) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 1948df9ac7..2c11cdbc7d 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -121,29 +121,32 @@ export function formatRelative(iso: string, now: number): string { /** * The timestamp a conversation row's relative time label reads. * - * The label has to explain where its row sits. Two sections order their rows - * by last activity whatever the sidebar's `sortMode` says: Recent (see - * {@link selectRecentConversationsWithReuse}) and Chat (see - * {@link selectChatConversationsWithReuse}). Their rows show `updated_at` even - * while the rest of the sidebar is sorted, and labelled, by creation time. A - * Chat row is recognised by what that bucket admits: a top-level chat-kind - * conversation that is not pinned (a pinned one sits in Pinned instead). - * - * Every other row follows `sortMode`. That covers the Folders rows, which are - * sorted by it; the Pinned rows, which are ordered by pin time, a stamp no - * label shows; and the sub-sessions under an expanded parent, which keep the - * creation order they have under their folder (see {@link mergeChildrenById}), - * so the copies under a Recent parent read the same as their twins there. + * The label has to explain where its row sits, so it reads the stamp the row + * is ordered by: + * - Sub-sessions read `created_at`. They are creation-ordered under every + * parent whatever the sidebar's `sortMode` says (see + * {@link mergeChildrenById}), so the copies under a Recent parent also read + * the same as their twins under the folder. Told apart by `parent_id`, the + * card's own test, not by depth: a folder's own rows sit at depth 1 too + * inside a folder group, and under Show worktrees. + * - Recent's and Chat's own rows read `updated_at`. Both sections order by + * last activity whatever `sortMode` says (see + * {@link selectRecentConversationsWithReuse} and + * {@link selectChatConversationsWithReuse}). A Chat row is recognised by what + * that bucket admits: a chat-kind conversation that is not pinned (a pinned + * one sits in Pinned instead). + * - Every other row follows `sortMode`: the Folders rows, which are sorted by + * it, and the Pinned rows, which are ordered by pin time, a stamp no label + * shows. */ export function conversationRowTimestamp( row: ConversationRow, sortMode: SidebarSortMode ): string { const conv = row.conversation - if (row.depth === 0) { - if (row.recent) return conv.updated_at - if (conv.kind === "chat" && conv.pinned_at == null) return conv.updated_at - } + if (conv.parent_id != null) return conv.created_at + if (row.recent) return conv.updated_at + if (conv.kind === "chat" && conv.pinned_at == null) return conv.updated_at return sortMode === "updated" ? conv.updated_at : conv.created_at } diff --git a/src/components/conversations/sidebar-conversation-list.tsx b/src/components/conversations/sidebar-conversation-list.tsx index a1143448f7..eb59181ac2 100644 --- a/src/components/conversations/sidebar-conversation-list.tsx +++ b/src/components/conversations/sidebar-conversation-list.tsx @@ -3186,9 +3186,10 @@ export function SidebarConversationList({ selectedConversation?.id === conv.id } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} - // The stamp the row's section orders it by, so the label explains its - // position: Recent's and Chat's own rows read their last activity even - // while the rest of the sidebar is sorted by creation time. + // The stamp the row is ordered by, so the label explains its position: + // Recent's and Chat's own rows read their last activity even while the + // rest of the sidebar is sorted by creation time, and sub-sessions + // their creation time even while it is sorted by activity. timeLabel={formatRelative(conversationRowTimestamp(row, sortMode), now)} onSelect={handleSelect} onDoubleClick={handleDoubleClick} From 2374043ad13c084714b0dbe48ee0fc9ef3545855 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 21 Oct 2026 19:01:45 +0800 Subject: [PATCH 5/6] test(sidebar): give the Recent ordering fixtures a possible chronology Two fixtures in the Recent ordering tests had a conversation whose last activity came before its creation, which real rows never have. They now use one created a moment ago and left alone, so the creation and activity orders still disagree for a reason that can happen. --- .../conversations/sidebar-conversation-grouping.test.ts | 4 ++-- .../conversations/sidebar-conversation-list.test.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 96c895d89a..b450d47433 100644 --- a/src/components/conversations/sidebar-conversation-grouping.test.ts +++ b/src/components/conversations/sidebar-conversation-grouping.test.ts @@ -1555,8 +1555,8 @@ describe("selectRecentConversationsWithReuse", () => { updated_at: new Date(9_000_000).toISOString(), }) const brandNew = conv(2, 10, { - created_at: new Date(9_000_000).toISOString(), - updated_at: new Date(1_000).toISOString(), + created_at: new Date(5_000_000).toISOString(), + updated_at: new Date(5_000_000).toISOString(), }) expect( selectRecentConversationsWithReuse( diff --git a/src/components/conversations/sidebar-conversation-list.test.tsx b/src/components/conversations/sidebar-conversation-list.test.tsx index 382e8d32e8..61567e91db 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1213,7 +1213,7 @@ describe("SidebarConversationList — Recent section", () => { updated_at: new Date(t0 - 30_000).toISOString(), }), conv(12, 1, { - created_at: new Date(t0 - MINUTE).toISOString(), + created_at: new Date(t0 - 4 * MINUTE).toISOString(), updated_at: new Date(t0 - 3 * MINUTE).toISOString(), }), ], @@ -1224,11 +1224,11 @@ describe("SidebarConversationList — Recent section", () => { // conv-12 is newer); everything after them is the Recent duplicate, in // activity order (conv-11 was touched last). Each row's text is its title // followed by its age label: the Folders copies keep their creation age - // ("1m", "5m"), while the Recent copies read their last activity ("now" + // ("4m", "5m"), while the Recent copies read their last activity ("now" // for 30s ago, "3m"), so each row's position is explained by what it shows. const rows = Array.from(document.querySelectorAll("[data-conv-key]")) expect(rows.map((el) => el.textContent)).toEqual([ - "conv-121m", + "conv-124m", "conv-115m", "conv-11now", "conv-123m", From 9cc3aeb3785aeabd66bacd3b1f4810ce457788c0 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 21 Oct 2026 19:06:04 +0800 Subject: [PATCH 6/6] docs(sidebar): describe the Recent section as most recently active --- .../conversations/sidebar-conversation-grouping.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/conversations/sidebar-conversation-grouping.ts b/src/components/conversations/sidebar-conversation-grouping.ts index 2c11cdbc7d..4b6832b9f6 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -1028,9 +1028,9 @@ export interface RecentMoreRow { * when there are pinned conversations) plus the three user-reorderable ones — * "folders" (wraps the whole folder list), "chats" (a flat list of folderless * chat-mode conversations), and "recent" (a flat, folder-agnostic list of the - * newest conversations, shown only when the user keeps it enabled). All live in - * the same flat row array so the single Virtualizer windows them like any other - * row — there is no separate, un-virtualized list. + * most recently active conversations, shown only when the user keeps it + * enabled). All live in the same flat row array so the single Virtualizer + * windows them like any other row — there is no separate, un-virtualized list. */ export interface SectionHeaderRow { kind: "section"