From 011d1a59355717a448e52d0460b3f7a854ed14b7 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:26:27 +0000 Subject: [PATCH 1/6] fix(cloud-agent-next): stabilize and filter the sidebar Remote rows Live sessions bypassed the sidebar's platform, project, and search filters, so the Remote section kept showing CLI rows while only Cloud was selected, and every CLI heartbeat prepended its connection's rows, so two or more live connections reshuffled the section every few seconds. Filter the live rows with the same selections the stored list gets, keep the cached order when a heartbeat merges in, and preserve the enriched fields a wire row lacks. Refs #7034, #6813 --- .../cloud-agent-next/ChatSidebar.tsx | 28 +++-- .../cloud-agent-next/CloudSidebarLayout.tsx | 20 +--- .../hooks/useActiveSessions.test.ts | 57 ++++++++-- .../hooks/useActiveSessions.ts | 92 ++++++++++++---- .../live-sidebar-sessions.test.ts | 100 ++++++++++++++++++ .../cloud-agent-next/live-sidebar-sessions.ts | 92 ++++++++++++++++ 6 files changed, 340 insertions(+), 49 deletions(-) create mode 100644 apps/web/src/components/cloud-agent-next/live-sidebar-sessions.test.ts create mode 100644 apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 598951b845d..709acb09ba6 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -71,6 +71,7 @@ import { type WorkspaceFolderDragItem, type WorkspaceFolderDropTarget, } from './workspace-folders'; +import { filterLiveSidebarSessions } from './live-sidebar-sessions'; type ActiveSession = { id: string; @@ -79,6 +80,9 @@ type ActiveSession = { connectionId: string; gitUrl?: string; gitBranch?: string; + /** Stored origin, and the origin the connected CLI reported. */ + createdOnPlatform?: string; + platform?: string; }; type ChatSidebarProps = { @@ -762,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 leave every row where it was, so the order is still 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/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index bb6fe67b77e..0c164a2e1ff 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -84,6 +84,7 @@ import { type PendingWorktreeChat, } from './worktree-chat-pending'; import { PendingFirstChatProvider } from './pending-first-chat'; +import { platformFilterValues } from './live-sidebar-sessions'; // Context for children to toggle the mobile sidebar sheet type WorktreeChatTabs = { @@ -251,21 +252,10 @@ export function CloudSidebarLayout({ const [pendingWorktreeChat, setPendingWorktreeChat] = useState(null); const repoUpdatedSince = useMemo(() => startOfDay(subDays(new Date(), 30)).toISOString(), []); - const createdOnPlatform = useMemo(() => { - if (platformFilter.length === 0) return undefined; - return platformFilter.flatMap(p => { - switch (p) { - // 'cloud-agent-web' is a variant of the cloud agent - case 'cloud-agent': - return ['cloud-agent', 'cloud-agent-web']; - // Extension sessions are created from VS Code or agent-manager - case 'extension': - return ['vscode', 'agent-manager']; - default: - return [p]; - } - }); - }, [platformFilter]); + const createdOnPlatform = useMemo( + () => (platformFilter.length === 0 ? undefined : platformFilterValues(platformFilter)), + [platformFilter] + ); const folderWorktreeIds = useMemo( () => workspaceFolders.folders.flatMap(folder => folder.worktreeIds), 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..d41bc5248c4 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 @@ -46,10 +46,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 +59,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 +120,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 +142,8 @@ 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' }, ]); }); 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..fb6edd5c151 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; + platform?: string; + createdAt?: string; + updatedAt?: string; + lastActivityAt?: string; +}; type CliConnectionPayload = { connectionId: string; @@ -51,31 +62,69 @@ function getCliConnectionPayload(value: unknown): CliConnectionPayload | null { return parsed.data; } +/** Fields `activeSessions.list` enriches from `cli_sessions_v2` and a wire row lacks. */ +const ENRICHED_FIELDS = [ + 'createdOnPlatform', + 'platform', + 'createdAt', + 'updatedAt', + 'lastActivityAt', +] as const; + /** - * 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. + * 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 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, - })); +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] ?? 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. + */ 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) { + if (session.connectionId !== payload.connectionId) { + merged.push(session); + continue; + } + const incoming = reported.get(session.id); + // A row the heartbeat no longer reports has ended on that connection. + if (!incoming) continue; + reported.delete(session.id); + merged.push(mergeCachedEnrichment(session, incoming)); + } + for (const session of reported.values()) { + merged.push(mergeCachedEnrichment(undefined, session)); + } + return merged; } export function removeActiveSessionsForConnection( @@ -133,7 +182,12 @@ export function useActiveSessions(): { if (event.event === 'sessions.list') { const sessions = getRootSessionsFromListPayload(event.data); if (sessions) { - updateCachedSessions(current => preserveCachedTitles(current, sessions)); + updateCachedSessions(current => { + const cachedById = new Map(current.map(session => [session.id, session])); + return sessions.map(session => + mergeCachedEnrichment(cachedById.get(session.id), session) + ); + }); } } 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..f794e795c4c --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.test.ts @@ -0,0 +1,100 @@ +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_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 either reported origin', () => { + const selected = new Set(['cli']); + expect(matchesLivePlatformFilter(liveSessions[0], selected)).toBe(true); + expect(matchesLivePlatformFilter({ id: 'ses_x', title: 'x', platform: 'cli' }, selected)).toBe( + true + ); + expect(matchesLivePlatformFilter(liveSessions[1], selected)).toBe(false); + }); + + it('claims a row with no reported origin by no selection', () => { + expect(matchesLivePlatformFilter(liveSessions[2], new Set(['cloud-agent']))).toBe(false); + expect(matchesLivePlatformFilter(liveSessions[2], new Set())).toBe(true); + }); +}); + +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, { + platformFilter: ['cloud-agent'], + projectFilter: ['git@github.com:acme/widgets.git'], + searchQuery: 'cloud', + }).map(row => row.id) + ).toEqual(['ses_web']); + + expect( + filterLiveSidebarSessions(liveSessions, { + platformFilter: ['cloud-agent'], + projectFilter: [], + searchQuery: 'REMOTE', + }) + ).toEqual([]); + }); + + it('matches the search against the title, id, and repository', () => { + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'widgets' }) + ).toHaveLength(2); + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'ses_remote' }).map( + row => row.id + ) + ).toEqual(['ses_remote']); + }); +}); 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..b16d344d15b --- /dev/null +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -0,0 +1,92 @@ +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`, present once the session has been + * ingested. `platform` is the origin the connected CLI reported on its + * heartbeat, so a live row has one even before ingestion. + */ + createdOnPlatform?: string; + platform?: 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; the stored list and the live rows + * both have to 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'], +}; + +/** 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]); +} + +/** + * Whether a live row belongs to the current platform selection. A row that + * reports no origin is claimed by no selection, so a filtered sidebar never + * shows a session it cannot attribute. + */ +export function matchesLivePlatformFilter( + session: LiveSidebarSession, + selectedPlatforms: ReadonlySet +): boolean { + if (selectedPlatforms.size === 0) return true; + const origins = [session.createdOnPlatform, session.platform].filter((origin): origin is string => + Boolean(origin) + ); + return origins.some(origin => selectedPlatforms.has(origin)); +} + +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. + * + * Comparison uses the normalized repository URL so one stored option still + * matches every spelling of it, matching the stored list's query. + */ +export function filterLiveSidebarSessions( + sessions: readonly T[], + query: LiveSidebarQuery +): T[] { + const needle = query.searchQuery.trim().toLowerCase(); + if (query.platformFilter.length === 0 && query.projectFilter.length === 0 && !needle) { + return [...sessions]; + } + const selectedPlatforms = new Set(platformFilterValues(query.platformFilter)); + const selectedProjects = new Set(query.projectFilter.map(normalizeGitUrl)); + return sessions.filter(session => { + const projectMatches = + selectedProjects.size === 0 || + (session.gitUrl != null && selectedProjects.has(normalizeGitUrl(session.gitUrl))); + return ( + matchesLivePlatformFilter(session, selectedPlatforms) && + projectMatches && + (!needle || matchesLiveSearch(session, needle)) + ); + }); +} From 503c06111a881c83e75977a45f45e5443caa3be1 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Wed, 30 Sep 2026 20:50:36 +0000 Subject: [PATCH 2/6] fix(cloud-agent-next): scope the sidebar Remote rows to the visible filter Adversarial review round on #7040: - the live list answered every organization plus personal, so switching to an org still showed another scope's Remote rows; scope it like the stored list - a session another connection takes over was kept alongside its new row, rendering it twice; replace it in place - the wire 'platform' field is the client the CLI runs in, not the origin, so it must not claim a platform filter - 'other' has to mean every origin outside KNOWN_PLATFORMS, as the stored query defines it, and the search has to match only title and id - keep the cached organization attribution, where null means personal Refs #7034, #6813 --- .../cloud-agent-next/ChatSidebar.tsx | 37 ++++----- .../cloud-agent-next/CloudSidebarLayout.tsx | 2 +- .../hooks/useActiveSessions.test.ts | 78 ++++++++++++++++++ .../hooks/useActiveSessions.ts | 74 ++++++++++++----- .../live-sidebar-sessions.test.ts | 79 +++++++++++++++---- .../cloud-agent-next/live-sidebar-sessions.ts | 79 ++++++++++++------- 6 files changed, 261 insertions(+), 88 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 709acb09ba6..0f2acbecdb5 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -80,9 +80,8 @@ type ActiveSession = { connectionId: string; gitUrl?: string; gitBranch?: string; - /** Stored origin, and the origin the connected CLI reported. */ + /** Stored origin from `cli_sessions_v2`, absent until the session is ingested. */ createdOnPlatform?: string; - platform?: string; }; type ChatSidebarProps = { @@ -768,27 +767,19 @@ export function ChatSidebar({ // 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 leave every row where it was, so the order is still decided - // here: attention first, then working, id last. - const remotePriority = (status: string) => - status === 'question' || status === 'permission' - ? 2 - : status === 'busy' || status === 'retry' - ? 1 - : 0; - 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]); + // scope and selections here or the sidebar shows rows the filters exclude. + const liveOnlySessions = useMemo( + () => + filterLiveSidebarSessions( + activeSessions.filter(activeS => !sessions.some(s => s.sessionId === activeS.id)), + { + organizationId: organizationId ?? null, + platformFilter: platformFilter ?? [], + projectFilter: projectFilter ?? [], + searchQuery, + } + ), + [activeSessions, sessions, organizationId, platformFilter, projectFilter, searchQuery] const hasActiveFilter = (platformFilter?.length ?? 0) > 0 || (projectFilter?.length ?? 0) > 0; diff --git a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 0c164a2e1ff..62691d0e57f 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -318,7 +318,7 @@ export function CloudSidebarLayout({ : session ); }, [currentSessionId, foregroundSessionStatus, sessions]); - const { activeSessions } = useActiveSessions(); + const { activeSessions } = useActiveSessions(organizationId ?? null); // Session deletion (lightweight - no stream cleanup, container handles that on unmount) const trpc = useTRPC(); 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 d41bc5248c4..897f7b18030 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, @@ -147,6 +148,83 @@ describe('useActiveSessions live payload helpers', () => { ]); }); + it('replaces a row in place when another connection takes it over', () => { + const sessions = applyActiveSessionsHeartbeat( + [ + { id: 'root-1', status: 'busy', title: 'Moved', connectionId: 'conn-1' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + ], + { + connectionId: 'conn-2', + sessions: [ + { id: 'root-1', status: 'busy', title: 'Moved', connectionId: 'conn-2' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + ], + } + ); + + expect(sessions).toEqual([ + { id: 'root-1', status: 'busy', title: 'Moved', connectionId: 'conn-2' }, + { id: 'root-2', status: 'busy', title: 'Other CLI', 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', + organizationId: null, + }, + { 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', + organizationId: null, + }, + ]); + }); + + it('keeps an org attribution through a heartbeat that omits it', () => { + const sessions = applyActiveSessionsHeartbeat( + [ + { + id: 'root-1', + status: 'idle', + title: 'Org session', + connectionId: 'conn-1', + organizationId: 'org_1', + }, + ], + { + connectionId: 'conn-1', + sessions: [{ id: 'root-1', status: 'busy', title: 'Org session', connectionId: 'conn-1' }], + } + ); + + expect(sessions).toEqual([ + { + id: 'root-1', + status: 'busy', + title: 'Org session', + connectionId: 'conn-1', + organizationId: 'org_1', + }, + ]); + }); + it('removes all cached rows for a disconnected connection', () => { const sessions = removeActiveSessionsForConnection( [ 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 fb6edd5c151..cc2b871e87e 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts @@ -20,10 +20,16 @@ import { useUserWebConnection } from '../CloudAgentProvider'; */ export type ActiveSession = ActiveSessionWithConnectionData & { createdOnPlatform?: string; - platform?: string; createdAt?: string; updatedAt?: string; lastActivityAt?: string; + statusUpdatedAt?: string; + /** + * Owning organization; `null` = personal. Always emitted by the query, and + * absent from a connection payload, so the merge keeps the last attribution + * instead of treating a known row as unattributed. + */ + organizationId?: string | null; }; type CliConnectionPayload = { @@ -62,13 +68,16 @@ function getCliConnectionPayload(value: unknown): CliConnectionPayload | null { return parsed.data; } -/** Fields `activeSessions.list` enriches from `cli_sessions_v2` and a wire row lacks. */ +/** + * Fields the sidebar reads that `activeSessions.list` enriches from + * `cli_sessions_v2` and a connection payload never carries. + */ const ENRICHED_FIELDS = [ 'createdOnPlatform', - 'platform', 'createdAt', 'updatedAt', 'lastActivityAt', + 'statusUpdatedAt', ] as const; /** @@ -89,9 +98,14 @@ function mergeCachedEnrichment( if (!cached) return incoming; const merged: ActiveSession = { ...incoming, title: cached.title }; for (const field of ENRICHED_FIELDS) { - const value = cached[field] ?? incoming[field]; + const value = cached[field] !== undefined ? cached[field] : incoming[field]; if (value !== undefined) merged[field] = value; } + // `organizationId: null` is a real attribution (personal), so only an absent + // value falls through to the incoming row. + const organizationId = + cached.organizationId !== undefined ? cached.organizationId : incoming.organizationId; + if (organizationId !== undefined) merged.organizationId = organizationId; return merged; } @@ -103,6 +117,10 @@ function mergeCachedEnrichment( * 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[], @@ -111,13 +129,13 @@ export function applyActiveSessionsHeartbeat( const reported = new Map(payload.sessions.map(session => [session.id, session])); const merged: ActiveSession[] = []; for (const session of currentSessions) { - if (session.connectionId !== payload.connectionId) { - merged.push(session); + 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; } - const incoming = reported.get(session.id); - // A row the heartbeat no longer reports has ended on that connection. - if (!incoming) continue; reported.delete(session.id); merged.push(mergeCachedEnrichment(session, incoming)); } @@ -127,6 +145,21 @@ export function applyActiveSessionsHeartbeat( 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( currentSessions: ActiveSession[], connectionId: string @@ -138,15 +171,25 @@ type ActiveSessionsQueryData = { sessions: ActiveSession[]; }; -export function useActiveSessions(): { +/** + * Live sessions for one scope. `organizationId` mirrors the stored sidebar + * list: a uuid for an organization, `null` for personal. The connection + * payload is not org-scoped, so the cached attribution decides which live rows + * the sidebar may show. + */ +export function useActiveSessions(organizationId: string | null): { activeSessions: ActiveSession[]; isLoading: boolean; } { const trpc = useTRPC(); const queryClient = useQueryClient(); const sharedConnection = useUserWebConnection(); - const activeSessionsQueryOptions = trpc.activeSessions.list.queryOptions(); - const activeSessionsQueryKey = useMemo(() => trpc.activeSessions.list.queryKey(), [trpc]); + const input = useMemo(() => ({ organizationId }), [organizationId]); + const activeSessionsQueryOptions = trpc.activeSessions.list.queryOptions(input); + const activeSessionsQueryKey = useMemo( + () => trpc.activeSessions.list.queryKey(input), + [trpc, input] + ); const { data, isLoading, refetch } = useQuery({ ...activeSessionsQueryOptions, refetchInterval: 10_000, @@ -182,12 +225,7 @@ export function useActiveSessions(): { if (event.event === 'sessions.list') { const sessions = getRootSessionsFromListPayload(event.data); if (sessions) { - updateCachedSessions(current => { - const cachedById = new Map(current.map(session => [session.id, session])); - return sessions.map(session => - mergeCachedEnrichment(cachedById.get(session.id), session) - ); - }); + 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 index f794e795c4c..c450defcb12 100644 --- 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 @@ -10,17 +10,31 @@ const liveSessions = [ title: 'Remote CLI session', gitUrl: 'https://github.com/acme/Widgets.git', createdOnPlatform: 'cli', + organizationId: null, }, { id: 'ses_web', title: 'Cloud web session', gitUrl: 'https://github.com/acme/widgets', createdOnPlatform: 'cloud-agent-web', + organizationId: null, }, + { + id: 'ses_org', + title: 'Org CLI session', + createdOnPlatform: 'cli', + organizationId: 'org_1', + }, + { id: 'ses_other', title: 'Custom client session', createdOnPlatform: 'my-tool' }, { id: 'ses_unattributed', title: 'Legacy CLI session' }, ]; -const noQuery = { platformFilter: [], projectFilter: [], searchQuery: '' }; +const noQuery = { + organizationId: null, + platformFilter: [], + projectFilter: [], + searchQuery: '', +}; describe('platformFilterValues', () => { it('expands a filter value to every stored platform it covers', () => { @@ -31,24 +45,47 @@ describe('platformFilterValues', () => { }); describe('matchesLivePlatformFilter', () => { - it('claims a row by either reported origin', () => { - const selected = new Set(['cli']); - expect(matchesLivePlatformFilter(liveSessions[0], selected)).toBe(true); - expect(matchesLivePlatformFilter({ id: 'ses_x', title: 'x', platform: 'cli' }, selected)).toBe( - true - ); - expect(matchesLivePlatformFilter(liveSessions[1], selected)).toBe(false); + 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[3], ['other'])).toBe(true); + expect(matchesLivePlatformFilter(liveSessions[3], ['cli'])).toBe(false); }); it('claims a row with no reported origin by no selection', () => { - expect(matchesLivePlatformFilter(liveSessions[2], new Set(['cloud-agent']))).toBe(false); - expect(matchesLivePlatformFilter(liveSessions[2], new Set())).toBe(true); + expect(matchesLivePlatformFilter(liveSessions[4], ['cloud-agent'])).toBe(false); + expect(matchesLivePlatformFilter(liveSessions[4], ['other'])).toBe(false); + expect(matchesLivePlatformFilter(liveSessions[4], [])).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('returns every row in scope when nothing is selected', () => { + expect(filterLiveSidebarSessions(liveSessions, noQuery).map(row => row.id)).toEqual([ + 'ses_remote', + 'ses_web', + 'ses_other', + 'ses_unattributed', + ]); + }); + + it('keeps a row another scope owns out of the sidebar', () => { + expect( + filterLiveSidebarSessions(liveSessions, { ...noQuery, organizationId: 'org_1' }).map( + row => row.id + ) + ).toEqual(['ses_org']); }); it('hides remote rows while only cloud is selected', () => { @@ -69,9 +106,10 @@ describe('filterLiveSidebarSessions', () => { expect(rows.map(row => row.id)).toEqual(['ses_remote']); }); - it('combines origin, repository, and text with AND', () => { + it('combines scope, origin, repository, and text with AND', () => { expect( filterLiveSidebarSessions(liveSessions, { + ...noQuery, platformFilter: ['cloud-agent'], projectFilter: ['git@github.com:acme/widgets.git'], searchQuery: 'cloud', @@ -80,6 +118,7 @@ describe('filterLiveSidebarSessions', () => { expect( filterLiveSidebarSessions(liveSessions, { + ...noQuery, platformFilter: ['cloud-agent'], projectFilter: [], searchQuery: 'REMOTE', @@ -87,14 +126,22 @@ describe('filterLiveSidebarSessions', () => { ).toEqual([]); }); - it('matches the search against the title, id, and repository', () => { + it('matches the search against the title and the id', () => { expect( - filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'widgets' }) - ).toHaveLength(2); + 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']); }); + + it('does not search the repository, which the stored list does not search either', () => { + expect(filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'widgets' })).toEqual( + [] + ); + }); }); 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 index b16d344d15b..5303dea88e5 100644 --- a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -1,3 +1,4 @@ +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. */ @@ -5,17 +6,23 @@ export type LiveSidebarSession = { id: string; title: string; gitUrl?: string; - gitBranch?: string; /** - * Stored origin from `cli_sessions_v2`, present once the session has been - * ingested. `platform` is the origin the connected CLI reported on its - * heartbeat, so a live row has one even before ingestion. + * 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; - platform?: string; + /** + * Owning organization; `null` = personal, and absent only on a row the query + * never attributed — which the server treats as personal too. + */ + organizationId?: string | null; }; export type LiveSidebarQuery = { + /** Scope the sidebar is showing. `null` = personal. */ + organizationId: string | null; platformFilter: readonly string[]; projectFilter: readonly string[]; searchQuery: string; @@ -23,8 +30,8 @@ export type LiveSidebarQuery = { /** * Filter values that cover more than one stored platform. The sidebar's - * platform picker names a platform family; the stored list and the live rows - * both have to collapse a family through this one map to stay in step. + * 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 @@ -33,58 +40,70 @@ const PLATFORM_FILTER_VARIANTS: Record = { 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]); } /** - * Whether a live row belongs to the current platform selection. A row that - * reports no origin is claimed by no selection, so a filtered sidebar never - * shows a session it cannot attribute. + * 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. */ export function matchesLivePlatformFilter( session: LiveSidebarSession, - selectedPlatforms: ReadonlySet + platformFilter: readonly string[] ): boolean { - if (selectedPlatforms.size === 0) return true; - const origins = [session.createdOnPlatform, session.platform].filter((origin): origin is string => - Boolean(origin) - ); - return origins.some(origin => selectedPlatforms.has(origin)); + if (platformFilter.length === 0) return true; + const bucket = livePlatformBucket(session.createdOnPlatform); + if (bucket === null) return false; + const selected = new Set(platformFilterValues(platformFilter)); + return selected.has(bucket); } +/** Matches what the stored list matches: the session id and its title. */ function matchesLiveSearch(session: LiveSidebarSession, needle: string): boolean { - return [session.title, session.id, session.gitUrl, session.gitBranch].some(value => - value?.toLowerCase().includes(needle) - ); + return session.title.toLowerCase().includes(needle) || session.id.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. + * Client-side filter for the live (Remote) rows: scope, 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. * - * Comparison uses the normalized repository URL so one stored option still - * matches every spelling of it, matching the stored list's query. + * 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[] { const needle = query.searchQuery.trim().toLowerCase(); - if (query.platformFilter.length === 0 && query.projectFilter.length === 0 && !needle) { - return [...sessions]; - } - const selectedPlatforms = new Set(platformFilterValues(query.platformFilter)); const selectedProjects = new Set(query.projectFilter.map(normalizeGitUrl)); return sessions.filter(session => { + const scopeMatches = (session.organizationId ?? null) === query.organizationId; const projectMatches = selectedProjects.size === 0 || (session.gitUrl != null && selectedProjects.has(normalizeGitUrl(session.gitUrl))); return ( - matchesLivePlatformFilter(session, selectedPlatforms) && + scopeMatches && + matchesLivePlatformFilter(session, query.platformFilter) && projectMatches && (!needle || matchesLiveSearch(session, needle)) ); From 20e69ddec4b7f7879c67d7eb223e4f8e406b3a44 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:00:37 +0000 Subject: [PATCH 3/6] fix(cloud-agent-next): drop the org scoping of the live session list Review round three on #7040: the connection payload carries no organization id, so scoping the live list client-side leaked another scope's rows into a personal view and hid a new row in an org view until the next poll, and the scoped query key silently broke the deletion cache write. The live list stays unscoped, as before; scoping it needs an org id on the wire. Also import the live row type from the hook instead of duplicating it, and make the takeover test fail on the old merge. Refs #7034, #6813 --- .../cloud-agent-next/ChatSidebar.tsx | 18 ++------ .../cloud-agent-next/CloudSidebarLayout.tsx | 2 +- .../hooks/useActiveSessions.test.ts | 46 ++++--------------- .../hooks/useActiveSessions.ts | 27 ++--------- .../live-sidebar-sessions.test.ts | 44 ++++-------------- .../cloud-agent-next/live-sidebar-sessions.ts | 17 ++----- 6 files changed, 30 insertions(+), 124 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 0f2acbecdb5..6c07d4e7f21 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -72,17 +72,7 @@ import { type WorkspaceFolderDropTarget, } from './workspace-folders'; import { filterLiveSidebarSessions } from './live-sidebar-sessions'; - -type ActiveSession = { - id: string; - status: string; - title: string; - connectionId: string; - gitUrl?: string; - gitBranch?: string; - /** Stored origin from `cli_sessions_v2`, absent until the session is ingested. */ - createdOnPlatform?: string; -}; +import type { ActiveSession } from './hooks/useActiveSessions'; type ChatSidebarProps = { sessions: StoredSession[]; @@ -767,19 +757,19 @@ export function ChatSidebar({ // 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 - // scope and selections here or the sidebar shows rows the filters exclude. + // selections here or the sidebar shows rows the filters exclude. const liveOnlySessions = useMemo( () => filterLiveSidebarSessions( activeSessions.filter(activeS => !sessions.some(s => s.sessionId === activeS.id)), { - organizationId: organizationId ?? null, platformFilter: platformFilter ?? [], projectFilter: projectFilter ?? [], searchQuery, } ), - [activeSessions, sessions, organizationId, platformFilter, projectFilter, searchQuery] + [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/CloudSidebarLayout.tsx b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx index 62691d0e57f..0c164a2e1ff 100644 --- a/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx +++ b/apps/web/src/components/cloud-agent-next/CloudSidebarLayout.tsx @@ -318,7 +318,7 @@ export function CloudSidebarLayout({ : session ); }, [currentSessionId, foregroundSessionStatus, sessions]); - const { activeSessions } = useActiveSessions(organizationId ?? null); + const { activeSessions } = useActiveSessions(); // Session deletion (lightweight - no stream cleanup, container handles that on unmount) const trpc = useTRPC(); 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 897f7b18030..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 @@ -148,24 +148,26 @@ describe('useActiveSessions live payload helpers', () => { ]); }); - it('replaces a row in place when another connection takes it over', () => { + it('keeps a row another connection takes over in place and without a duplicate', () => { const sessions = applyActiveSessionsHeartbeat( [ - { id: 'root-1', status: 'busy', title: 'Moved', connectionId: 'conn-1' }, - { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + { 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-1', status: 'busy', title: 'Moved', connectionId: 'conn-2' }, - { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + { 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: 'Moved', connectionId: 'conn-2' }, - { id: 'root-2', status: 'busy', title: 'Other CLI', connectionId: 'conn-2' }, + { 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' }, ]); }); @@ -178,7 +180,6 @@ describe('useActiveSessions live payload helpers', () => { title: 'DB title', connectionId: 'conn-1', createdOnPlatform: 'cli', - organizationId: null, }, { id: 'root-3', status: 'busy', title: 'Gone', connectionId: 'conn-1' }, ], @@ -192,35 +193,6 @@ describe('useActiveSessions live payload helpers', () => { title: 'DB title', connectionId: 'conn-1', createdOnPlatform: 'cli', - organizationId: null, - }, - ]); - }); - - it('keeps an org attribution through a heartbeat that omits it', () => { - const sessions = applyActiveSessionsHeartbeat( - [ - { - id: 'root-1', - status: 'idle', - title: 'Org session', - connectionId: 'conn-1', - organizationId: 'org_1', - }, - ], - { - connectionId: 'conn-1', - sessions: [{ id: 'root-1', status: 'busy', title: 'Org session', connectionId: 'conn-1' }], - } - ); - - expect(sessions).toEqual([ - { - id: 'root-1', - status: 'busy', - title: 'Org session', - connectionId: 'conn-1', - organizationId: 'org_1', }, ]); }); 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 cc2b871e87e..8a7448ac466 100644 --- a/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts +++ b/apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts @@ -24,12 +24,6 @@ export type ActiveSession = ActiveSessionWithConnectionData & { updatedAt?: string; lastActivityAt?: string; statusUpdatedAt?: string; - /** - * Owning organization; `null` = personal. Always emitted by the query, and - * absent from a connection payload, so the merge keeps the last attribution - * instead of treating a known row as unattributed. - */ - organizationId?: string | null; }; type CliConnectionPayload = { @@ -101,11 +95,6 @@ function mergeCachedEnrichment( const value = cached[field] !== undefined ? cached[field] : incoming[field]; if (value !== undefined) merged[field] = value; } - // `organizationId: null` is a real attribution (personal), so only an absent - // value falls through to the incoming row. - const organizationId = - cached.organizationId !== undefined ? cached.organizationId : incoming.organizationId; - if (organizationId !== undefined) merged.organizationId = organizationId; return merged; } @@ -171,25 +160,15 @@ type ActiveSessionsQueryData = { sessions: ActiveSession[]; }; -/** - * Live sessions for one scope. `organizationId` mirrors the stored sidebar - * list: a uuid for an organization, `null` for personal. The connection - * payload is not org-scoped, so the cached attribution decides which live rows - * the sidebar may show. - */ -export function useActiveSessions(organizationId: string | null): { +export function useActiveSessions(): { activeSessions: ActiveSession[]; isLoading: boolean; } { const trpc = useTRPC(); const queryClient = useQueryClient(); const sharedConnection = useUserWebConnection(); - const input = useMemo(() => ({ organizationId }), [organizationId]); - const activeSessionsQueryOptions = trpc.activeSessions.list.queryOptions(input); - const activeSessionsQueryKey = useMemo( - () => trpc.activeSessions.list.queryKey(input), - [trpc, input] - ); + const activeSessionsQueryOptions = trpc.activeSessions.list.queryOptions(); + const activeSessionsQueryKey = useMemo(() => trpc.activeSessions.list.queryKey(), [trpc]); const { data, isLoading, refetch } = useQuery({ ...activeSessionsQueryOptions, refetchInterval: 10_000, 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 index c450defcb12..3b7dd65eb98 100644 --- 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 @@ -10,31 +10,18 @@ const liveSessions = [ title: 'Remote CLI session', gitUrl: 'https://github.com/acme/Widgets.git', createdOnPlatform: 'cli', - organizationId: null, }, { id: 'ses_web', title: 'Cloud web session', gitUrl: 'https://github.com/acme/widgets', createdOnPlatform: 'cloud-agent-web', - organizationId: null, - }, - { - id: 'ses_org', - title: 'Org CLI session', - createdOnPlatform: 'cli', - organizationId: 'org_1', }, { id: 'ses_other', title: 'Custom client session', createdOnPlatform: 'my-tool' }, { id: 'ses_unattributed', title: 'Legacy CLI session' }, ]; -const noQuery = { - organizationId: null, - platformFilter: [], - projectFilter: [], - searchQuery: '', -}; +const noQuery = { platformFilter: [], projectFilter: [], searchQuery: '' }; describe('platformFilterValues', () => { it('expands a filter value to every stored platform it covers', () => { @@ -52,14 +39,14 @@ describe('matchesLivePlatformFilter', () => { }); it('claims an origin outside the platform catalogue by the other bucket', () => { - expect(matchesLivePlatformFilter(liveSessions[3], ['other'])).toBe(true); - expect(matchesLivePlatformFilter(liveSessions[3], ['cli'])).toBe(false); + 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[4], ['cloud-agent'])).toBe(false); - expect(matchesLivePlatformFilter(liveSessions[4], ['other'])).toBe(false); - expect(matchesLivePlatformFilter(liveSessions[4], [])).toBe(true); + 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', () => { @@ -71,21 +58,8 @@ describe('matchesLivePlatformFilter', () => { }); describe('filterLiveSidebarSessions', () => { - it('returns every row in scope when nothing is selected', () => { - expect(filterLiveSidebarSessions(liveSessions, noQuery).map(row => row.id)).toEqual([ - 'ses_remote', - 'ses_web', - 'ses_other', - 'ses_unattributed', - ]); - }); - - it('keeps a row another scope owns out of the sidebar', () => { - expect( - filterLiveSidebarSessions(liveSessions, { ...noQuery, organizationId: 'org_1' }).map( - row => row.id - ) - ).toEqual(['ses_org']); + it('returns every row when nothing is selected', () => { + expect(filterLiveSidebarSessions(liveSessions, noQuery)).toEqual(liveSessions); }); it('hides remote rows while only cloud is selected', () => { @@ -106,7 +80,7 @@ describe('filterLiveSidebarSessions', () => { expect(rows.map(row => row.id)).toEqual(['ses_remote']); }); - it('combines scope, origin, repository, and text with AND', () => { + it('combines origin, repository, and text with AND', () => { expect( filterLiveSidebarSessions(liveSessions, { ...noQuery, 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 index 5303dea88e5..ba1d7449fa9 100644 --- a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -13,16 +13,9 @@ export type LiveSidebarSession = { * origin the session was created on. */ createdOnPlatform?: string; - /** - * Owning organization; `null` = personal, and absent only on a row the query - * never attributed — which the server treats as personal too. - */ - organizationId?: string | null; }; export type LiveSidebarQuery = { - /** Scope the sidebar is showing. `null` = personal. */ - organizationId: string | null; platformFilter: readonly string[]; projectFilter: readonly string[]; searchQuery: string; @@ -81,10 +74,10 @@ function matchesLiveSearch(session: LiveSidebarSession, needle: string): boolean } /** - * Client-side filter for the live (Remote) rows: scope, 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. + * 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 @@ -97,12 +90,10 @@ export function filterLiveSidebarSessions( const needle = query.searchQuery.trim().toLowerCase(); const selectedProjects = new Set(query.projectFilter.map(normalizeGitUrl)); return sessions.filter(session => { - const scopeMatches = (session.organizationId ?? null) === query.organizationId; const projectMatches = selectedProjects.size === 0 || (session.gitUrl != null && selectedProjects.has(normalizeGitUrl(session.gitUrl))); return ( - scopeMatches && matchesLivePlatformFilter(session, query.platformFilter) && projectMatches && (!needle || matchesLiveSearch(session, needle)) From 5318870e831e57c877437007791c38f73aa5f177 Mon Sep 17 00:00:00 2001 From: maphew <486200+maphew@users.noreply.github.com> Date: Fri, 2 Oct 2026 04:49:31 +0000 Subject: [PATCH 4/6] fix(cloud-agent-next): match live sidebar search on repository and branch --- .../live-sidebar-sessions.test.ts | 32 ++++++++++++++++--- .../cloud-agent-next/live-sidebar-sessions.ts | 9 ++++-- 2 files changed, 34 insertions(+), 7 deletions(-) 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 index 3b7dd65eb98..24da78d20a5 100644 --- 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 @@ -100,7 +100,7 @@ describe('filterLiveSidebarSessions', () => { ).toEqual([]); }); - it('matches the search against the title and the id', () => { + it('matches the search against the title, the id, and the repository', () => { expect( filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'legacy' }).map( row => row.id @@ -111,11 +111,33 @@ describe('filterLiveSidebarSessions', () => { 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('does not search the repository, which the stored list does not search either', () => { - expect(filterLiveSidebarSessions(liveSessions, { ...noQuery, searchQuery: 'widgets' })).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 index ba1d7449fa9..d355f5802d8 100644 --- a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -6,6 +6,7 @@ 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: @@ -68,9 +69,13 @@ export function matchesLivePlatformFilter( return selected.has(bucket); } -/** Matches what the stored list matches: the session id and its title. */ +/** Matches what the stored list matches: id, title, repository, and branch. */ function matchesLiveSearch(session: LiveSidebarSession, needle: string): boolean { - return session.title.toLowerCase().includes(needle) || session.id.toLowerCase().includes(needle); + const normalizedNeedle = needle.startsWith('#') ? needle.slice(1) : needle; + if (normalizedNeedle.length === 0) return true; + return [session.title, session.id, session.gitUrl, session.gitBranch].some(value => + value?.toLowerCase().includes(normalizedNeedle) + ); } /** From 7f92781e6eb1f92a05095b654f05abf9c671a649 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Fri, 2 Oct 2026 22:09:10 -0700 Subject: [PATCH 5/6] fix(cloud-agent-next): keep the Remote rows in attention-first order The heartbeat merge now leaves every row where it was, so the Remote section still needs the ordering main ships: attention first, then working, then id. Apply it after the live filter instead of instead of it, so a filtered sidebar keeps the order an unfiltered one has. --- .../cloud-agent-next/ChatSidebar.tsx | 32 ++++++++++++------- 1 file changed, 20 insertions(+), 12 deletions(-) diff --git a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx index 6c07d4e7f21..94f6be820e5 100644 --- a/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx +++ b/apps/web/src/components/cloud-agent-next/ChatSidebar.tsx @@ -758,18 +758,26 @@ export function ChatSidebar({ // 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( - () => - filterLiveSidebarSessions( - activeSessions.filter(activeS => !sessions.some(s => s.sessionId === activeS.id)), - { - platformFilter: platformFilter ?? [], - projectFilter: projectFilter ?? [], - searchQuery, - } - ), - [activeSessions, sessions, platformFilter, projectFilter, searchQuery] - ); + 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 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; From 1fceafce30fa514eafa7626660c0d3e0f6f33500 Mon Sep 17 00:00:00 2001 From: matt wilkie Date: Fri, 2 Oct 2026 22:09:14 -0700 Subject: [PATCH 6/6] fix(cloud-agent-next): match the stored search for an empty needle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A query that trims away to nothing — a bare `#`, or whitespace — leaves the stored list empty, because the search resolves an empty needle to zero rows. The live filter returned every row for it instead, so the Remote section ignored the query the rest of the sidebar had applied. Hoist the platform selection out of the row loop at the same time: it was rebuilt per row, on every heartbeat. --- .../live-sidebar-sessions.test.ts | 5 +++ .../cloud-agent-next/live-sidebar-sessions.ts | 36 ++++++++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) 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 index 24da78d20a5..de08edd67f1 100644 --- 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 @@ -123,6 +123,11 @@ describe('filterLiveSidebarSessions', () => { ).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, 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 index d355f5802d8..1568f1d6070 100644 --- a/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts +++ b/apps/web/src/components/cloud-agent-next/live-sidebar-sessions.ts @@ -58,23 +58,27 @@ function livePlatformBucket(createdOnPlatform: string | undefined): string | nul * 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; - const bucket = livePlatformBucket(session.createdOnPlatform); - if (bucket === null) return false; - const selected = new Set(platformFilterValues(platformFilter)); - return selected.has(bucket); + 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 { - const normalizedNeedle = needle.startsWith('#') ? needle.slice(1) : needle; - if (normalizedNeedle.length === 0) return true; return [session.title, session.id, session.gitUrl, session.gitBranch].some(value => - value?.toLowerCase().includes(normalizedNeedle) + value?.toLowerCase().includes(needle) ); } @@ -92,16 +96,22 @@ export function filterLiveSidebarSessions( sessions: readonly T[], query: LiveSidebarQuery ): T[] { - const needle = query.searchQuery.trim().toLowerCase(); + // 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))); - return ( - matchesLivePlatformFilter(session, query.platformFilter) && - projectMatches && - (!needle || matchesLiveSearch(session, needle)) - ); + const searchMatches = + !isSearchActive || (needle.length > 0 && matchesLiveSearch(session, needle)); + return platformMatches && projectMatches && searchMatches; }); }