From ab694586b2ba8a5a39c6e97d66ae16dfe2257bba Mon Sep 17 00:00:00 2001 From: Pebrd Date: Thu, 11 Jun 2026 14:27:54 -0300 Subject: [PATCH 1/2] fix: resolve pinned Telegram sessions into sidebar Pinned section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sessionByAnyId map only indexed cronSessions and visibleSessions (local CLI chats), so a pinned gateway session (Telegram, etc.) stored its pinId in localStorage but could never be resolved back to a SessionInfo object — the Pinned section rendered empty for those. Include messagingSessions in the lookup so pinned gateway chats appear correctly at the top of the sidebar. --- apps/desktop/src/app/chat/sidebar/index.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 0d82ef78725..5ce15f29f1d 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -395,13 +395,15 @@ export function ChatSidebar({ // Index sessions by both their live id and their lineage-root id so a pin // stored as the pre-compression root resolves to the live continuation tip. + // Include messaging sessions (Telegram, etc.) so pinned gateway chats also + // resolve into the Pinned section. const sessionByAnyId = useMemo(() => { const map = new Map() // Cron sessions are listed separately but can still be pinned, so index // them too — otherwise a pinned cron job can't resolve into the Pinned // section. Recents take precedence on id collisions (set last). - for (const s of [...cronSessions, ...visibleSessions]) { + for (const s of [...cronSessions, ...messagingSessions, ...visibleSessions]) { map.set(s.id, s) if (s._lineage_root_id && !map.has(s._lineage_root_id)) { From 46b55c2c9143f1ef4b6856d823e1ccf6653d8770 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 29 Jul 2026 12:12:55 -0500 Subject: [PATCH 2/2] refactor(desktop): extract the pin index and cover the messaging slice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pebrd's fix is right but the `useMemo` it edits didn't list `messagingSessions` as a dependency, so the index would go stale as the messaging slice refreshed. Rather than regex around logic buried in a 1500-line component, lift it into `buildSessionByAnyId` where the contract can be tested directly — the extraction LeonSGP43 proposed in sibling PR #49896. The test asserts the invariant that actually matters: a pin resolves from every slice the sidebar fetches. Reverting the function to its two-slice form fails it on the messaging and lineage-root cases. Co-authored-by: LeonSGP43 --- apps/desktop/src/app/chat/sidebar/index.tsx | 27 +++-------- .../app/chat/sidebar/session-index.test.ts | 48 +++++++++++++++++++ .../src/app/chat/sidebar/session-index.ts | 33 +++++++++++++ 3 files changed, 88 insertions(+), 20 deletions(-) create mode 100644 apps/desktop/src/app/chat/sidebar/session-index.test.ts create mode 100644 apps/desktop/src/app/chat/sidebar/session-index.ts diff --git a/apps/desktop/src/app/chat/sidebar/index.tsx b/apps/desktop/src/app/chat/sidebar/index.tsx index 5ce15f29f1d..3609791ff36 100644 --- a/apps/desktop/src/app/chat/sidebar/index.tsx +++ b/apps/desktop/src/app/chat/sidebar/index.tsx @@ -130,6 +130,7 @@ import { useRepoWorktreeMap } from './projects' import { SidebarBlankState, SidebarPinnedEmptyState, SidebarSessionSkeletons } from './section-states' +import { buildSessionByAnyId } from './session-index' import { SidebarSessionsSection, VIRTUALIZE_THRESHOLD } from './sessions-section' import { CONTEXT_SPLIT_KIT, SplitSubmenu } from './split-submenu' @@ -393,26 +394,12 @@ export function ChatSidebar({ const workingSessionIdSet = useMemo(() => new Set(workingSessionIds), [workingSessionIds]) - // Index sessions by both their live id and their lineage-root id so a pin - // stored as the pre-compression root resolves to the live continuation tip. - // Include messaging sessions (Telegram, etc.) so pinned gateway chats also - // resolve into the Pinned section. - const sessionByAnyId = useMemo(() => { - const map = new Map() - - // Cron sessions are listed separately but can still be pinned, so index - // them too — otherwise a pinned cron job can't resolve into the Pinned - // section. Recents take precedence on id collisions (set last). - for (const s of [...cronSessions, ...messagingSessions, ...visibleSessions]) { - map.set(s.id, s) - - if (s._lineage_root_id && !map.has(s._lineage_root_id)) { - map.set(s._lineage_root_id, s) - } - } - - return map - }, [visibleSessions, cronSessions]) + // Index sessions by every id a pin might be stored under — recents, cron, + // AND messaging, since all three can be pinned (see session-index.ts). + const sessionByAnyId = useMemo( + () => buildSessionByAnyId(visibleSessions, cronSessions, messagingSessions), + [visibleSessions, cronSessions, messagingSessions] + ) const pinnedSessions = useMemo(() => { const seen = new Set() diff --git a/apps/desktop/src/app/chat/sidebar/session-index.test.ts b/apps/desktop/src/app/chat/sidebar/session-index.test.ts new file mode 100644 index 00000000000..bee60a64603 --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-index.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import type { SessionInfo } from '@/types/hermes' + +import { buildSessionByAnyId } from './session-index' + +const row = (id: string, extra: Partial = {}): SessionInfo => + ({ id, message_count: 1, source: 'cli', started_at: 0, title: id, ...extra }) as SessionInfo + +describe('buildSessionByAnyId', () => { + it('resolves a pin from every slice the sidebar fetches', () => { + // The contract that matters: a pin is looked up in this map no matter + // which slice owns the row. Messaging is the one that regressed — a + // pinned session is filtered out of its own section, so a miss here + // removes it from the sidebar entirely rather than just misplacing it. + const index = buildSessionByAnyId([row('recent')], [row('cron_job_1')], [row('telegram_42')]) + + for (const id of ['recent', 'cron_job_1', 'telegram_42']) { + expect(index.get(id)?.id).toBe(id) + } + }) + + it('resolves a pin stored on the pre-compression lineage root', () => { + const index = buildSessionByAnyId([], [], [row('tip', { _lineage_root_id: 'root' })]) + + // Both identities point at the one live row. + expect(index.get('root')?.id).toBe('tip') + expect(index.get('tip')?.id).toBe('tip') + }) + + it('lets a recents row win a direct id collision', () => { + const index = buildSessionByAnyId( + [row('dupe', { title: 'from recents' })], + [], + [row('dupe', { title: 'from messaging' })] + ) + + expect(index.get('dupe')?.title).toBe('from recents') + }) + + it('does not let a lineage alias clobber a real row under that id', () => { + // 'root' is a live session in its own right AND another row's lineage + // root; the real row must win so the pin opens the right conversation. + const index = buildSessionByAnyId([row('root')], [], [row('tip', { _lineage_root_id: 'root' })]) + + expect(index.get('root')?.id).toBe('root') + }) +}) diff --git a/apps/desktop/src/app/chat/sidebar/session-index.ts b/apps/desktop/src/app/chat/sidebar/session-index.ts new file mode 100644 index 00000000000..486d977366b --- /dev/null +++ b/apps/desktop/src/app/chat/sidebar/session-index.ts @@ -0,0 +1,33 @@ +import type { SessionInfo } from '@/types/hermes' + +/** + * Index sessions by every id a pin might be stored under. + * + * The sidebar fetches three independent slices — recents, cron, and messaging + * — and renders the latter two in self-managed sections. Any of them can be + * pinned, so all three must be indexed here or the Pinned section can't + * resolve the pin to a row. A pinned session is also filtered out of its own + * section, so failing to index it doesn't merely misplace the row: it removes + * the session from the sidebar entirely. + * + * Each session is keyed under both its live id and its lineage root, so a pin + * stored before an auto-compression still resolves to the live continuation + * tip. Recents are indexed last and win a direct id collision. + */ +export function buildSessionByAnyId( + visibleSessions: SessionInfo[], + cronSessions: SessionInfo[], + messagingSessions: SessionInfo[] +): Map { + const map = new Map() + + for (const session of [...cronSessions, ...messagingSessions, ...visibleSessions]) { + map.set(session.id, session) + + if (session._lineage_root_id && !map.has(session._lineage_root_id)) { + map.set(session._lineage_root_id, session) + } + } + + return map +}