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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

* 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 <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-06-08 13:31:09 +08:00
committed by GitHub
co-authored by Cursor
parent ad038bbf2e
commit 8094b500f3
5 changed files with 196 additions and 10 deletions
+1 -1
View File
@@ -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);
+12
View File
@@ -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')
})
})
+3 -1
View File
@@ -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 ? {
+137 -1
View File
@@ -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<SessionSummary> & { 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({
+43 -7
View File
@@ -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<string, SessionSummary[]>()
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<string, { directory: string; machineId: string | null; sessions: SessionSummary[] }>()
@@ -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: {
<div className="text-xs text-[var(--app-hint)]">
{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 })}
</div>
<button
type="button"