mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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);
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ? {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user