diff --git a/src/components/conversations/sidebar-conversation-grouping.test.ts b/src/components/conversations/sidebar-conversation-grouping.test.ts index 9281b2b732..b450d47433 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,139 @@ 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, { + 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. + 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("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], + ]) + expect(kids.map((r) => conversationRowTimestamp(r, "created"))).toEqual([ + created, + 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, + ]) + }) + + 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", () => { 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)] @@ -1331,7 +1466,6 @@ describe("selectRecentConversationsWithReuse", () => { selectRecentConversationsWithReuse( [folderConv, chatConv], true, - "created", open, [], filter @@ -1350,7 +1484,6 @@ describe("selectRecentConversationsWithReuse", () => { selectRecentConversationsWithReuse( [fromCli, fromWindow, chat, closedCli], true, - "created", open, [], "cli" @@ -1362,7 +1495,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [conv(2, 77)], true, - "created", open, [], "folders" @@ -1377,7 +1509,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [folderConv, chatConv, pinned], true, - "created", open, [] ) @@ -1392,7 +1523,6 @@ describe("selectRecentConversationsWithReuse", () => { const out = selectRecentConversationsWithReuse( [inOpen, inClosed, chatConv], true, - "created", open, [] ) @@ -1406,43 +1536,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(5_000_000).toISOString(), + updated_at: new Date(5_000_000).toISOString(), + }) expect( selectRecentConversationsWithReuse( - [older, newer], + [longRunning, brandNew], true, - "updated", open, [] ).map((c) => c.id) @@ -1451,20 +1570,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..4b6832b9f6 100644 --- a/src/components/conversations/sidebar-conversation-grouping.ts +++ b/src/components/conversations/sidebar-conversation-grouping.ts @@ -118,6 +118,38 @@ 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, 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 (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 +} + function arraysShallowEqual(a: readonly T[], b: readonly T[]): boolean { if (a === b) return true if (a.length !== b.length) return false @@ -251,8 +283,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 @@ -278,9 +312,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 +329,13 @@ 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. 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. @@ -306,7 +345,6 @@ export function selectChatConversationsWithReuse( export function selectRecentConversationsWithReuse( conversations: readonly DbConversationSummary[], showCompleted: boolean, - sortMode: SidebarSortMode, openFolderIds: ReadonlySet, prev: DbConversationSummary[], filter: SidebarRecentFilter = "all" @@ -324,9 +362,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 } @@ -992,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" @@ -1219,8 +1255,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..61567e91db 100644 --- a/src/components/conversations/sidebar-conversation-list.test.tsx +++ b/src/components/conversations/sidebar-conversation-list.test.tsx @@ -1197,6 +1197,44 @@ 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 - 4 * 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). Each row's text is its title + // followed by its age label: the Folders copies keep their creation age + // ("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-124m", + "conv-115m", + "conv-11now", + "conv-123m", + ]) + }) + 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. @@ -1439,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 29ecf49668..eb59181ac2 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, @@ -1407,22 +1408,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,10 +3186,11 @@ export function SidebarConversationList({ selectedConversation?.id === conv.id } isOpenInTab={openTabKeys.has(`${conv.agent_type}:${conv.id}`)} - timeLabel={formatRelative( - sortMode === "updated" ? conv.updated_at : conv.created_at, - now - )} + // 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} onRename={handleRename}