Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 18 additions & 17 deletions apps/web/src/components/cloud-agent-next/ChatSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -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;

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
applyActiveSessionsHeartbeat,
applyActiveSessionsList,
getRootSessionsFromHeartbeatPayload,
getRootSessionsFromListPayload,
removeActiveSessionsForConnection,
Expand Down Expand Up @@ -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' },
],
{
Expand All @@ -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' },
],
{
Expand All @@ -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' }],
{
Expand All @@ -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',
},
]);
});

Expand Down
109 changes: 90 additions & 19 deletions apps/web/src/components/cloud-agent-next/hooks/useActiveSessions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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') {
Expand Down
Loading