From 8094b500f32dc24f374ea1bc7cd7e619794ec0af Mon Sep 17 00:00:00 2001 From: SSU-WEI HUANG Date: Mon, 8 Jun 2026 13:31:09 +0800 Subject: [PATCH] fix(web): hide sidebar fake sessions for Cursor resume/archive (#836) * fix(web): dedupe sidebar sessions by flavor resume id Wire deduplicateSessionsByAgentId into SessionList and resolve cursor threads via cursorSessionId so resume/archive no longer shows duplicate inactive rows for the same ACP session. Fixes #833 Co-authored-by: Cursor * fix(web): use SessionSummary.agentSessionId for sidebar dedup SessionList only receives SessionSummary from the API; native ids like cursorSessionId are already mapped into metadata.agentSessionId by toSessionSummary. Drop resolveAgentSessionIdFromMetadata to fix typecheck. Co-authored-by: Cursor * fix(web): hide inactive empty session stubs in sidebar Filter inactive rows with no agentSessionId and no title signal before grouping sessions, and expose lifecycleState on SessionSummary for future sidebar rules. Completes the #833 P0 follow-up alongside agent-id dedup. Fixes #833 Co-authored-by: Cursor * fix(web): scope sidebar dedup key by flavor Prevent cross-flavor collisions when flattened agentSessionId retains a stale native id. Add regression test and relax claudeRemote CI timeout. Fixes #833 Co-authored-by: Cursor --------- Co-authored-by: Cursor --- cli/src/claude/claudeRemote.test.ts | 2 +- shared/src/sessionSummary.test.ts | 12 +++ shared/src/sessionSummary.ts | 4 +- web/src/components/SessionList.test.ts | 138 ++++++++++++++++++++++++- web/src/components/SessionList.tsx | 50 +++++++-- 5 files changed, 196 insertions(+), 10 deletions(-) diff --git a/cli/src/claude/claudeRemote.test.ts b/cli/src/claude/claudeRemote.test.ts index dd0a11cc..60723281 100644 --- a/cli/src/claude/claudeRemote.test.ts +++ b/cli/src/claude/claudeRemote.test.ts @@ -141,7 +141,7 @@ describe('claudeRemote async message handling', () => { queryMock.mockReset(); querySpy.mockRestore(); } - }); + }, 15_000); it('handles rejected next user message fetch without unhandled rejection', async () => { const querySpy = vi.spyOn(claudeSdk, 'query').mockImplementation(queryMock as typeof claudeSdk.query); diff --git a/shared/src/sessionSummary.test.ts b/shared/src/sessionSummary.test.ts index a82b8b77..a0c8845a 100644 --- a/shared/src/sessionSummary.test.ts +++ b/shared/src/sessionSummary.test.ts @@ -74,4 +74,16 @@ describe('toSessionSummary', () => { expect(summary.backgroundTaskCount).toBe(2) expect(summary.futureScheduledMessageCount).toBe(0) }) + + it('includes lifecycleState in summary metadata', () => { + const summary = toSessionSummary(makeSession({ + metadata: { + path: '/proj', + host: 'local', + lifecycleState: 'archived' + } + })) + + expect(summary.metadata?.lifecycleState).toBe('archived') + }) }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 97e45ee3..16421c4b 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -18,6 +18,7 @@ export type SessionSummaryMetadata = { flavor?: string | null worktree?: WorktreeMetadata agentSessionId?: string + lifecycleState?: string } export type SessionSummary = { @@ -68,7 +69,8 @@ export function toSessionSummary(session: Session): SessionSummary { ?? session.metadata.opencodeSessionId ?? session.metadata.cursorSessionId ?? session.metadata.kimiSessionId - ?? undefined + ?? undefined, + lifecycleState: session.metadata.lifecycleState } : null const todoProgress = session.todos?.length ? { diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index 23a4deec..7d2270d3 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -1,6 +1,16 @@ import { describe, expect, it } from 'vitest' import type { SessionSummary } from '@/types/api' -import { deduplicateSessionsByAgentId, expandSelectedSessionCollapseOverrides, getVisibleSessionPreview, normalizeSearch, sessionMatchesQuery } from './SessionList' +import { + deduplicateSessionsByAgentId, + expandSelectedSessionCollapseOverrides, + getSessionDedupKey, + getVisibleSessionPreview, + isSidebarEmptySessionStub, + normalizeSearch, + prepareSidebarSessions, + sessionMatchesQuery, + shouldShowSessionInSidebar +} from './SessionList' function makeSession(overrides: Partial & { id: string }): SessionSummary { return { @@ -71,6 +81,25 @@ describe('deduplicateSessionsByAgentId', () => { expect(result).toHaveLength(3) }) + it('deduplicates cursor sessions by summary agentSessionId', () => { + const sessions = [ + makeSession({ + id: 'a', + active: true, + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, + updatedAt: 100 + }), + makeSession({ + id: 'b', + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'acp-thread-1' }, + updatedAt: 200 + }) + ] + const result = deduplicateSessionsByAgentId(sessions) + expect(result).toHaveLength(1) + expect(result[0].id).toBe('a') + }) + it('deduplicates independently across different agentSessionIds', () => { const sessions = [ makeSession({ id: 'a', metadata: { path: '/p', agentSessionId: 'thread-1' }, updatedAt: 100 }), @@ -82,9 +111,116 @@ describe('deduplicateSessionsByAgentId', () => { expect(result).toHaveLength(2) expect(result.map(s => s.id).sort()).toEqual(['b', 'd']) }) + + it('does not dedupe across flavors sharing the same flattened agentSessionId', () => { + const sessions = [ + makeSession({ + id: 'codex', + metadata: { path: '/p', flavor: 'codex', agentSessionId: 'stale-shared-id' }, + updatedAt: 100 + }), + makeSession({ + id: 'cursor', + metadata: { path: '/p', flavor: 'cursor', agentSessionId: 'stale-shared-id' }, + updatedAt: 200 + }) + ] + + expect(getSessionDedupKey(sessions[0])).toBe('codex:stale-shared-id') + expect(getSessionDedupKey(sessions[1])).toBe('cursor:stale-shared-id') + expect(deduplicateSessionsByAgentId(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) + expect(prepareSidebarSessions(sessions).map(session => session.id).sort()).toEqual(['codex', 'cursor']) + }) }) +describe('isSidebarEmptySessionStub', () => { + it('treats inactive sessions without agent id or title as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'stub', + metadata: { path: '/work/hapi' } + }))).toBe(true) + }) + + it('does not treat active sessions as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'live', + active: true, + metadata: { path: '/work/hapi' } + }))).toBe(false) + }) + + it('does not treat sessions with agentSessionId as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'resume', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } + }))).toBe(false) + }) + + it('does not treat sessions with summary text as stubs', () => { + expect(isSidebarEmptySessionStub(makeSession({ + id: 'titled', + metadata: { path: '/work/hapi', summary: { text: 'Fix sidebar' } } + }))).toBe(false) + }) +}) + +describe('prepareSidebarSessions', () => { + it('hides inactive empty stubs but keeps real sessions', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'real', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1', summary: { text: 'Real chat' } } + }) + ] + + const result = prepareSidebarSessions(sessions) + expect(result.map(session => session.id)).toEqual(['real']) + }) + + it('keeps the selected inactive stub visible', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'real', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' } + }) + ] + + const result = prepareSidebarSessions(sessions, 'stub') + expect(result.map(session => session.id).sort()).toEqual(['real', 'stub']) + }) + + it('deduplicates before filtering stubs', () => { + const sessions = [ + makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }), + makeSession({ + id: 'older', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, + updatedAt: 100 + }), + makeSession({ + id: 'newer', + metadata: { path: '/work/hapi', agentSessionId: 'thread-1' }, + updatedAt: 200 + }) + ] + + const result = prepareSidebarSessions(sessions) + expect(result.map(session => session.id)).toEqual(['newer']) + }) +}) + +describe('shouldShowSessionInSidebar', () => { + it('always shows active and selected sessions', () => { + const stub = makeSession({ id: 'stub', metadata: { path: '/work/hapi' } }) + expect(shouldShowSessionInSidebar(stub)).toBe(false) + expect(shouldShowSessionInSidebar(stub, 'stub')).toBe(true) + expect(shouldShowSessionInSidebar({ ...stub, active: true })).toBe(true) + }) +}) + describe('session list search helpers', () => { it('normalizes whitespace and case before filtering', () => { const session = makeSession({ diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index 2cf627ec..a173e7ae 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -101,21 +101,29 @@ function getGroupDisplayName(directory: string): string { export const UNKNOWN_MACHINE_ID = '__unknown__' export const GROUP_SESSION_PREVIEW_LIMIT = DEFAULT_SESSION_PREVIEW_LIMIT +export function getSessionDedupKey(session: SessionSummary): string | null { + const agentId = session.metadata?.agentSessionId?.trim() + if (!agentId) return null + // Scope by flavor: agentSessionId is flattened from native ids and can retain a + // stale cross-flavor value (codexSessionId ?? claudeSessionId ?? ...). + return `${session.metadata?.flavor ?? 'unknown'}:${agentId}` +} + export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { const byAgentId = new Map() const result: SessionSummary[] = [] for (const session of sessions) { - const agentId = session.metadata?.agentSessionId - if (!agentId) { + const dedupKey = getSessionDedupKey(session) + if (!dedupKey) { result.push(session) continue } - const group = byAgentId.get(agentId) + const group = byAgentId.get(dedupKey) if (group) { group.push(session) } else { - byAgentId.set(agentId, [session]) + byAgentId.set(dedupKey, [session]) } } @@ -134,6 +142,34 @@ export function deduplicateSessionsByAgentId(sessions: SessionSummary[], selecte return result } +function hasSidebarTitleSignal(session: SessionSummary): boolean { + const meta = session.metadata + if (!meta) return false + if (meta.name?.trim()) return true + if (meta.summary?.text?.trim()) return true + return false +} + +export function isSidebarEmptySessionStub(session: SessionSummary): boolean { + if (session.active) return false + const meta = session.metadata + if (!meta) return true + if (meta.agentSessionId?.trim()) return false + if (hasSidebarTitleSignal(session)) return false + return true +} + +export function shouldShowSessionInSidebar(session: SessionSummary, selectedSessionId?: string | null): boolean { + if (session.id === selectedSessionId) return true + if (session.active) return true + return !isSidebarEmptySessionStub(session) +} + +export function prepareSidebarSessions(sessions: SessionSummary[], selectedSessionId?: string | null): SessionSummary[] { + return deduplicateSessionsByAgentId(sessions, selectedSessionId) + .filter(session => shouldShowSessionInSidebar(session, selectedSessionId)) +} + function groupSessionsByDirectory(sessions: SessionSummary[]): SessionGroup[] { const groups = new Map() @@ -771,8 +807,8 @@ export function SessionList(props: { } const allSessions = useMemo( - () => props.sessions, - [props.sessions] + () => prepareSidebarSessions(props.sessions, selectedSessionId), + [props.sessions, selectedSessionId] ) const visibleSessions = useMemo( () => isSearching @@ -920,7 +956,7 @@ export function SessionList(props: {
{isSearching ? t('sessions.search.count', { n: visibleSessions.length, total: allSessions.length }) - : t('sessions.count', { n: props.sessions.length, m: allGroups.length })} + : t('sessions.count', { n: allSessions.length, m: allGroups.length })}