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 })}