diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 98eb53b09ea..830534144b3 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -72,15 +72,8 @@ import { type WorkspaceFolderDragItem, type WorkspaceFolderDropTarget, } from './workspace-folders'; - -type ActiveSession = { - id: string; - status: string; - title: string; - connectionId: string; - gitUrl?: string; - gitBranch?: string; -}; +import { filterLiveSidebarSessions } from './live-sidebar-sessions'; +import type { ActiveSession } from './hooks/useActiveSessions'; type ChatSidebarProps = { sessions: StoredSession[]; @@ -773,21 +766,29 @@ export function ChatSidebar({ [activeSessions] ); - // Heartbeats reorder activeSessions per connection, so the Remote section - // needs its own stable order: attention first, then working, id last. + // Live sessions the stored list does not carry. The live rows bypass the + // server-side filter the stored list gets, so they have to honor the same + // selections here or the sidebar shows rows the filters exclude. const liveOnlySessions = useMemo(() => { + // Heartbeats now leave every row where it was, so the order is decided + // here: attention first, then working, id last. const remotePriority = (status: string) => status === 'question' || status === 'permission' ? 2 : status === 'busy' || status === 'retry' ? 1 : 0; - return activeSessions - .filter(activeS => !sessions.some(s => s.sessionId === activeS.id)) - .sort( - (a, b) => remotePriority(b.status) - remotePriority(a.status) || a.id.localeCompare(b.id) - ); - }, [activeSessions, sessions]); + return filterLiveSidebarSessions( + activeSessions.filter(activeS => !sessions.some(s => s.sessionId === activeS.id)), + { + platformFilter: platformFilter ?? [], + projectFilter: projectFilter ?? [], + searchQuery, + } + ).toSorted( + (a, b) => remotePriority(b.status) - remotePriority(a.status) || a.id.localeCompare(b.id) + ); + }, [activeSessions, sessions, platformFilter, projectFilter, searchQuery]); const hasActiveFilter = (platformFilter?.length ?? 0) > 0 || (projectFilter?.length ?? 0) > 0; diff --git a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts index e66c74a3829..2bde2fb6bcb 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.test.ts @@ -1,5 +1,6 @@ import { applyActiveSessionsHeartbeat, + applyActiveSessionsList, getRootSessionsFromHeartbeatPayload, getRootSessionsFromListPayload, removeActiveSessionsForConnection, @@ -46,10 +47,10 @@ describe('useActiveSessions live payload helpers', () => { expect(payload).toEqual({ connectionId: 'conn-1', sessions: [] }); }); - it('replaces all cached rows owned by a connection on heartbeat', () => { + it('drops cached rows the heartbeat no longer reports and appends new ones', () => { const sessions = applyActiveSessionsHeartbeat( [ - { id: 'root-1', status: 'idle', title: 'Stale', connectionId: 'conn-1' }, + { id: 'root-1', status: 'idle', title: 'Ended', connectionId: 'conn-1' }, { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, ], { @@ -59,15 +60,51 @@ describe('useActiveSessions live payload helpers', () => { ); expect(sessions).toEqual([ - { id: 'root-3', status: 'busy', title: 'New', connectionId: 'conn-1' }, { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + { id: 'root-3', status: 'busy', title: 'New', connectionId: 'conn-1' }, + ]); + }); + + it('keeps every row where it was when connections heartbeat in turn', () => { + const connA = [ + { id: 'a1', status: 'busy', title: 'A1', connectionId: 'conn-a' }, + { id: 'a2', status: 'busy', title: 'A2', connectionId: 'conn-a' }, + ]; + const connB = [ + { id: 'b1', status: 'busy', title: 'B1', connectionId: 'conn-b' }, + { id: 'b2', status: 'busy', title: 'B2', connectionId: 'conn-b' }, + ]; + const settled = [...connA, ...connB]; + + const afterA = applyActiveSessionsHeartbeat(settled, { + connectionId: 'conn-a', + sessions: connA.map(session => ({ ...session, status: 'idle' })), + }); + const afterB = applyActiveSessionsHeartbeat(afterA, { + connectionId: 'conn-b', + sessions: connB, + }); + + expect(afterA.map(session => session.id)).toEqual(['a1', 'a2', 'b1', 'b2']); + expect(afterB.map(session => session.id)).toEqual(['a1', 'a2', 'b1', 'b2']); + expect(afterB).toEqual([ + { id: 'a1', status: 'idle', title: 'A1', connectionId: 'conn-a' }, + { id: 'a2', status: 'idle', title: 'A2', connectionId: 'conn-a' }, + ...connB, ]); }); it('keeps the cached title for a known id and takes every other field from the heartbeat', () => { const sessions = applyActiveSessionsHeartbeat( [ - { id: 'root-1', status: 'idle', title: 'DB title', connectionId: 'conn-1' }, + { + id: 'root-1', + status: 'idle', + title: 'DB title', + connectionId: 'conn-1', + createdOnPlatform: 'cli', + lastActivityAt: '2026-09-30 10:00:00+00', + }, { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, ], { @@ -84,12 +121,19 @@ describe('useActiveSessions live payload helpers', () => { ); expect(sessions).toEqual([ - { id: 'root-1', status: 'busy', title: 'DB title', connectionId: 'conn-1' }, + { + id: 'root-1', + status: 'busy', + title: 'DB title', + connectionId: 'conn-1', + createdOnPlatform: 'cli', + lastActivityAt: '2026-09-30 10:00:00+00', + }, { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, ]); }); - it('uses the heartbeat title for a brand-new session id', () => { + it('appends a brand-new session id after the rows already on screen', () => { const sessions = applyActiveSessionsHeartbeat( [{ id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }], { @@ -99,8 +143,57 @@ describe('useActiveSessions live payload helpers', () => { ); expect(sessions).toEqual([ - { id: 'root-3', status: 'busy', title: 'CLI title', connectionId: 'conn-1' }, { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + { id: 'root-3', status: 'busy', title: 'CLI title', connectionId: 'conn-1' }, + ]); + }); + + it('keeps a row another connection takes over in place and without a duplicate', () => { + const sessions = applyActiveSessionsHeartbeat( + [ + { id: 'root-1', status: 'busy', title: 'Untouched', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Moved', connectionId: 'conn-1' }, + { id: 'root-3', status: 'busy', title: 'Stayed', connectionId: 'conn-2' }, + ], + { + connectionId: 'conn-2', + sessions: [ + { id: 'root-2', status: 'busy', title: 'Moved', connectionId: 'conn-2' }, + { id: 'root-3', status: 'busy', title: 'Stayed', connectionId: 'conn-2' }, + ], + } + ); + + expect(sessions).toEqual([ + { id: 'root-1', status: 'busy', title: 'Untouched', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Moved', connectionId: 'conn-2' }, + { id: 'root-3', status: 'busy', title: 'Stayed', connectionId: 'conn-2' }, + ]); + }); + + it('carries the cached enrichment through a full sessions.list snapshot', () => { + const sessions = applyActiveSessionsList( + [ + { + id: 'root-1', + status: 'idle', + title: 'DB title', + connectionId: 'conn-1', + createdOnPlatform: 'cli', + }, + { id: 'root-3', status: 'busy', title: 'Gone', connectionId: 'conn-1' }, + ], + [{ id: 'root-1', status: 'busy', title: 'Stale CLI title', connectionId: 'conn-1' }] + ); + + expect(sessions).toEqual([ + { + id: 'root-1', + status: 'busy', + title: 'DB title', + connectionId: 'conn-1', + createdOnPlatform: 'cli', + }, ]); }); diff --git a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts index 16cea04c403..8a7448ac466 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts @@ -13,7 +13,18 @@ import { } from '@kilocode/cloud-agent-sdk/schemas'; import { useUserWebConnection } from '../CloudAgentProvider'; -export type ActiveSession = ActiveSessionWithConnectionData; +/** + * A live row as the sidebar needs it: the connection wire shape plus the + * fields `activeSessions.list` enriches from `cli_sessions_v2`. A WebSocket + * payload carries neither, so the merge below keeps the enriched copy. + */ +export type ActiveSession = ActiveSessionWithConnectionData & { + createdOnPlatform?: string; + createdAt?: string; + updatedAt?: string; + lastActivityAt?: string; + statusUpdatedAt?: string; +}; type CliConnectionPayload = { connectionId: string; @@ -52,30 +63,90 @@ function getCliConnectionPayload(value: unknown): CliConnectionPayload | null { } /** - * The title is DB-authoritative (`activeSessions.list` enriches it from - * cli_sessions_v2); WS rows carry the CLI's own title, which stays stale - * forever after a cloud rename. Keep what the last fetch put in the cache for - * ids we already know; a brand-new id still shows its CLI title immediately. + * Fields the sidebar reads that `activeSessions.list` enriches from + * `cli_sessions_v2` and a connection payload never carries. */ -function preserveCachedTitles( - currentSessions: ActiveSession[], - incoming: ActiveSession[] -): ActiveSession[] { - const titleById = new Map(currentSessions.map(session => [session.id, session.title])); - return incoming.map(session => ({ - ...session, - title: titleById.get(session.id) ?? session.title, - })); +const ENRICHED_FIELDS = [ + 'createdOnPlatform', + 'createdAt', + 'updatedAt', + 'lastActivityAt', + 'statusUpdatedAt', +] as const; + +/** + * Overlay the cache onto a wire row for an id we already know. + * + * `activeSessions.list` enriches a row from `cli_sessions_v2` and the wire row + * is worse on every field this touches: the title is DB-authoritative (nothing + * propagates a cloud rename back to the CLI, so the wire title stays stale + * forever), and the origin and timestamp fields are absent from the wire + * entirely. Dropping them on each heartbeat would also drop a live row out of + * a filtered sidebar until the next poll refetch. A brand-new id still shows + * exactly what the connection reported. + */ +function mergeCachedEnrichment( + cached: ActiveSession | undefined, + incoming: ActiveSession +): ActiveSession { + if (!cached) return incoming; + const merged: ActiveSession = { ...incoming, title: cached.title }; + for (const field of ENRICHED_FIELDS) { + const value = cached[field] !== undefined ? cached[field] : incoming[field]; + if (value !== undefined) merged[field] = value; + } + return merged; } +/** + * Merge one connection's heartbeat into the cached list. + * + * The cached order is preserved: a row keeps the position it already had, and + * only ids the heartbeat introduces are appended. Prepending the heartbeating + * connection instead would move its rows ahead of every other connection on + * every heartbeat, so two or more live connections reshuffle the sidebar every + * few seconds and a row is never where the user last clicked it. + * + * A row another connection now owns is replaced in place rather than kept + * alongside the reported one: the connection list attributes each session to + * one connection, so a takeover must not render it twice. + */ export function applyActiveSessionsHeartbeat( currentSessions: ActiveSession[], payload: RootHeartbeatPayload ): ActiveSession[] { - return [ - ...preserveCachedTitles(currentSessions, payload.sessions), - ...currentSessions.filter(session => session.connectionId !== payload.connectionId), - ]; + const reported = new Map(payload.sessions.map(session => [session.id, session])); + const merged: ActiveSession[] = []; + for (const session of currentSessions) { + const incoming = reported.get(session.id); + if (!incoming) { + // A row this connection no longer reports has ended on it. A row another + // connection owns is untouched here. + if (session.connectionId !== payload.connectionId) merged.push(session); + continue; + } + reported.delete(session.id); + merged.push(mergeCachedEnrichment(session, incoming)); + } + for (const session of reported.values()) { + merged.push(mergeCachedEnrichment(undefined, session)); + } + return merged; +} + +/** + * Merge the full connection snapshot into the cached list. The payload already + * carries every connection's rows in the server's order, so only the enriched + * fields the wire lacks have to be carried over. + */ +export function applyActiveSessionsList( + currentSessions: ActiveSession[], + incomingSessions: ActiveSession[] +): ActiveSession[] { + const cachedById = new Map(currentSessions.map(session => [session.id, session])); + return incomingSessions.map(session => + mergeCachedEnrichment(cachedById.get(session.id), session) + ); } export function removeActiveSessionsForConnection( @@ -133,7 +204,7 @@ export function useActiveSessions(): { if (event.event === 'sessions.list') { const sessions = getRootSessionsFromListPayload(event.data); if (sessions) { - updateCachedSessions(current => preserveCachedTitles(current, sessions)); + updateCachedSessions(current => applyActiveSessionsList(current, sessions)); } } if (event.event === 'sessions.heartbeat') { diff --git a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.test.ts b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.test.ts new file mode 100644 index 00000000000..de08edd67f1 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.test.ts @@ -0,0 +1,148 @@ +import { + filterLiveSidebarSessions, + matchesLivePlatformFilter, + platformFilterValues, +} from './live-sidebar-sessions'; + +const liveSessions = [ + { + id: 'ses_remote', + title: 'Remote CLI session', + gitUrl: 'https://github.com/acme/Widgets.git', + createdOnPlatform: 'cli', + }, + { + id: 'ses_web', + title: 'Cloud web session', + gitUrl: 'https://github.com/acme/widgets', + createdOnPlatform: 'cloud-agent-web', + }, + { id: 'ses_other', title: 'Custom client session', createdOnPlatform: 'my-tool' }, + { id: 'ses_unattributed', title: 'Legacy CLI session' }, +]; + +const noQuery = { platformFilter: [], projectFilter: [], searchQuery: '' }; + +describe('platformFilterValues', () => { + it('expands a filter value to every stored platform it covers', () => { + expect(platformFilterValues(['cloud-agent'])).toEqual(['cloud-agent', 'cloud-agent-web']); + expect(platformFilterValues(['extension'])).toEqual(['vscode', 'agent-manager']); + expect(platformFilterValues(['cli', 'slack'])).toEqual(['cli', 'slack']); + }); +}); + +describe('matchesLivePlatformFilter', () => { + it('claims a row by its stored origin', () => { + expect(matchesLivePlatformFilter(liveSessions[0], ['cli'])).toBe(true); + expect(matchesLivePlatformFilter(liveSessions[1], ['cloud-agent'])).toBe(true); + expect(matchesLivePlatformFilter(liveSessions[1], ['cli'])).toBe(false); + }); + + it('claims an origin outside the platform catalogue by the other bucket', () => { + expect(matchesLivePlatformFilter(liveSessions[2], ['other'])).toBe(true); + expect(matchesLivePlatformFilter(liveSessions[2], ['cli'])).toBe(false); + }); + + it('claims a row with no reported origin by no selection', () => { + expect(matchesLivePlatformFilter(liveSessions[3], ['cloud-agent'])).toBe(false); + expect(matchesLivePlatformFilter(liveSessions[3], ['other'])).toBe(false); + expect(matchesLivePlatformFilter(liveSessions[3], [])).toBe(true); + }); + + it('never claims a row by the client the connection runs in', () => { + const darwinRow = { id: 'ses_darwin', title: 'macOS CLI', platform: 'darwin' }; + + expect(matchesLivePlatformFilter(darwinRow, ['cli'])).toBe(false); + expect(matchesLivePlatformFilter(darwinRow, ['other'])).toBe(false); + }); +}); + +describe('filterLiveSidebarSessions', () => { + it('returns every row when nothing is selected', () => { + expect(filterLiveSidebarSessions(liveSessions, noQuery)).toEqual(liveSessions); + }); + + it('hides remote rows while only cloud is selected', () => { + const rows = filterLiveSidebarSessions(liveSessions, { + ...noQuery, + platformFilter: ['cloud-agent'], + }); + + expect(rows.map(row => row.id)).toEqual(['ses_web']); + }); + + it('keeps remote rows while the cli platform is selected', () => { + const rows = filterLiveSidebarSessions(liveSessions, { + ...noQuery, + platformFilter: ['cli'], + }); + + expect(rows.map(row => row.id)).toEqual(['ses_remote']); + }); + + it('combines origin, repository, and text with AND', () => { + expect( + filterLiveSidebarSessions(liveSessions, { + ...noQuery, + platformFilter: ['cloud-agent'], + projectFilter: ['git@github.com:acme/widgets.git'], + searchQuery: 'cloud', + }).map(row => row.id) + ).toEqual(['ses_web']); + + expect( + filterLiveSidebarSessions(liveSessions, { + ...noQuery, + platformFilter: ['cloud-agent'], + projectFilter: [], + searchQuery: 'REMOTE', + }) + ).toEqual([]); + }); + + it('matches the search against the title, the id, and the repository', () => { + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'legacy' }).map( + row => row.id + ) + ).toEqual(['ses_unattributed']); + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'ses_remote' }).map( + row => row.id + ) + ).toEqual(['ses_remote']); + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'widgets' }).map( + row => row.id + ) + ).toEqual(['ses_remote', 'ses_web']); + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: '#ses_remote' }).map( + row => row.id + ) + ).toEqual(['ses_remote']); + }); + + it('matches nothing for a query that reduces to an empty needle', () => { + expect(filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: '#' })).toEqual([]); + expect(filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: ' ' })).toEqual([]); + }); + + it('matches the search against the branch', () => { + const withBranch = [ + ...liveSessions, + { + id: 'ses_branch', + title: 'Branched session', + gitUrl: 'https://github.com/acme/other.git', + gitBranch: 'feature/cool-thing', + createdOnPlatform: 'cli', + }, + ]; + expect( + filterLiveSidebarSessions(withBranch, { ...noQuery, searchQuery: 'cool-thing' }).map( + row => row.id + ) + ).toEqual(['ses_branch']); + }); +}); diff --git a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts new file mode 100644 index 00000000000..1568f1d6070 --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -0,0 +1,117 @@ +import { KNOWN_PLATFORMS } from '@kilocode/app-shared/platforms'; +import { normalizeGitUrl } from '@kilocode/worker-utils/normalize-git-url'; + +/** The part of a live session the sidebar filters read. */ +export type LiveSidebarSession = { + id: string; + title: string; + gitUrl?: string; + gitBranch?: string; + /** + * Stored origin from `cli_sessions_v2`, absent until the session has been + * ingested. The connection's own `platform` is deliberately not read here: + * that field is the client the CLI runs in ("darwin", "vscode"), not the + * origin the session was created on. + */ + createdOnPlatform?: string; +}; + +export type LiveSidebarQuery = { + platformFilter: readonly string[]; + projectFilter: readonly string[]; + searchQuery: string; +}; + +/** + * Filter values that cover more than one stored platform. The sidebar's + * platform picker names a platform family, and the stored list and the live + * rows both collapse a family through this one map to stay in step. + */ +const PLATFORM_FILTER_VARIANTS: Record = { + // 'cloud-agent-web' is a variant of the cloud agent + 'cloud-agent': ['cloud-agent', 'cloud-agent-web'], + // Extension sessions are created from VS Code or agent-manager + extension: ['vscode', 'agent-manager'], +}; + +const KNOWN_PLATFORM_VALUES = new Set(KNOWN_PLATFORMS); + +/** Stored platform values a filter selection covers. Empty selection = no filter. */ +export function platformFilterValues(platformFilter: readonly string[]): string[] { + return platformFilter.flatMap(platform => PLATFORM_FILTER_VARIANTS[platform] ?? [platform]); +} + +/** + * The filter bucket a stored origin falls in: the origin itself when the + * platform is known, "other" when it is not — the same split the stored + * session query makes, where "other" is every platform outside + * `KNOWN_PLATFORMS`. A row with no reported origin is claimed by no bucket, + * so a filtered sidebar never shows a session it cannot attribute. + */ +function livePlatformBucket(createdOnPlatform: string | undefined): string | null { + if (!createdOnPlatform) return null; + return KNOWN_PLATFORM_VALUES.has(createdOnPlatform) ? createdOnPlatform : 'other'; +} + +/** + * Whether a live row belongs to the current platform selection. Mirrors the + * stored list's query, including "other" matching any origin the platform + * catalogue does not name. + */ +function matchesLivePlatformBucket( + session: LiveSidebarSession, + selectedPlatforms: ReadonlySet +): boolean { + const bucket = livePlatformBucket(session.createdOnPlatform); + if (bucket === null) return false; + return selectedPlatforms.has(bucket); +} + +export function matchesLivePlatformFilter( + session: LiveSidebarSession, + platformFilter: readonly string[] +): boolean { + if (platformFilter.length === 0) return true; + return matchesLivePlatformBucket(session, new Set(platformFilterValues(platformFilter))); +} + +/** Matches what the stored list matches: id, title, repository, and branch. */ +function matchesLiveSearch(session: LiveSidebarSession, needle: string): boolean { + return [session.title, session.id, session.gitUrl, session.gitBranch].some(value => + value?.toLowerCase().includes(needle) + ); +} + +/** + * Client-side filter for the live (Remote) rows: origin, repository, and free + * text, combined with AND. An empty selection or an empty query means "no + * filter". The live rows are already in memory, so this filters locally — the + * stored list is filtered by the same selections server-side. + * + * Repository comparison normalizes both sides: the option comes from a stored + * row while the live row carries the URL the connection reported, and the two + * routinely disagree on spelling. + */ +export function filterLiveSidebarSessions( + sessions: readonly T[], + query: LiveSidebarQuery +): T[] { + // Mirrors the stored search: trim, lower-case, one leading `#` stripped. A + // query that reduces to an empty needle matches nothing rather than + // everything, which is what the stored search does with it. + const trimmedQuery = query.searchQuery.trim().toLowerCase(); + const needle = trimmedQuery.startsWith('#') ? trimmedQuery.slice(1) : trimmedQuery; + const isSearchActive = query.searchQuery.length > 0; + const selectedPlatforms = new Set(platformFilterValues(query.platformFilter)); + const selectedProjects = new Set(query.projectFilter.map(normalizeGitUrl)); + return sessions.filter(session => { + const platformMatches = + selectedPlatforms.size === 0 || matchesLivePlatformBucket(session, selectedPlatforms); + const projectMatches = + selectedProjects.size === 0 || + (session.gitUrl != null && selectedProjects.has(normalizeGitUrl(session.gitUrl))); + const searchMatches = + !isSearchActive || (needle.length > 0 && matchesLiveSearch(session, needle)); + return platformMatches && projectMatches && searchMatches; + }); +}