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
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
import { clearAllPrompts, clearApprovalRequest } from '@/store/prompts'
import { markRuntimeGone } from '@/store/runtime-gone'
import { getSessionOwnerHint, knownSessionOwner, ownerLookupSessionRows, requestSessionResume } from '@/store/session'
import { notifySessionsDeleted } from '@/store/session-live-deletion'
import type { SessionOwnerScope } from '@/store/session-request-router'
import { $sessionTiles, dropSessionState, sessionTileDelegate, unbindTileRuntime } from '@/store/session-states'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
Expand All @@ -27,6 +28,16 @@ import { ingestBackendSkin } from '@/themes/backend-sync'

import type { GatewayEventContext } from './types'

const changeHandlers: Partial<Record<GatewayEvent['type'], (event: GatewayEvent) => void>> = {
'pet.changed': event => notifyPetChanged(event.payload as PetChangeMeta | undefined),
'cron.changed': notifyCronChanged,
'sessions.changed': notifySessionsChanged,
'projects.changed': notifyProjectsChanged,
'platforms.changed': notifyPlatformsChanged,
'pairing.changed': notifyPairingChanged,
'sessions.deleted': event => notifySessionsDeleted((event as GatewayEvent<'sessions.deleted'>).payload)
}

function onGatewayReady(ready: GatewayEvent<'gateway.ready'>['payload'], activeSource: boolean): void {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
Expand Down Expand Up @@ -74,33 +85,13 @@ export function handleLifecycleEvent(ctx: GatewayEventContext): boolean {
return true
}

if (
event.type === 'pet.changed' ||
event.type === 'cron.changed' ||
event.type === 'sessions.changed' ||
event.type === 'projects.changed' ||
event.type === 'platforms.changed' ||
event.type === 'pairing.changed'
) {
// Change-watcher broadcasts (server._broadcast_watched_changes): the
// backend's on-disk signature moved. Route to the live-sync ticks the
// former pollers now subscribe to. Only the active source+profile's
// changes apply — background profile sockets (and other connections'
// gateways) watch their own homes.
const changeHandler = changeHandlers[event.type]

if (changeHandler) {
// Other connections' gateways watch their own homes; only the active
// source can publish into this sidebar. The payload owns the profile.
if (fromActiveSource()) {
if (event.type === 'pet.changed') {
notifyPetChanged(payload as PetChangeMeta | undefined)
} else if (event.type === 'cron.changed') {
notifyCronChanged()
} else if (event.type === 'projects.changed') {
notifyProjectsChanged()
} else if (event.type === 'platforms.changed') {
notifyPlatformsChanged()
} else if (event.type === 'pairing.changed') {
notifyPairingChanged()
} else {
notifySessionsChanged()
}
changeHandler(event)
}

return true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,26 @@ describe('refreshSessions identity + loading hygiene', () => {
removals.untombstoneSessions(['doomed'])
})

it('fences an externally deleted profile row but admits an unseen foreign twin', async () => {
const pending = deferred<SidebarSessionsResponse>()
listSidebarSessions.mockReturnValue(pending.promise)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'all' }))
const refresh = result.current.refreshSessions()
const removals = await import('@/store/session-removal')
removals.recordProfileSessionRemovals(['external-twin'], 'default')

await act(async () => {
pending.resolve(
sidebar({
sessions: [row('external-twin', { profile: 'default' }), row('external-twin', { profile: 'other' })]
})
)
await refresh
})

expect($sessions.get().map(s => [s.id, s.profile])).toEqual([['external-twin', 'other']])
})

it('re-admits a row whose archive rolled back mid-flight (release edge)', async () => {
// A failed archive untombstones immediately; a page that raced the
// rollback must still list the row — the guard only vetoes rows whose
Expand Down
13 changes: 8 additions & 5 deletions apps/desktop/src/app/session/hooks/use-session-list-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ function dropTombstoned(sessions: SessionInfo[]): SessionInfo[] {
// re-admits the row as before.
function dropRemovalRaced(sessions: SessionInfo[], snapshot: SessionTombstoneGenerationSnapshot): SessionInfo[] {
const raced = (session: SessionInfo): boolean =>
tombstoneRowIds(session).some(id => sessionRemovalIntersected(snapshot, id))
tombstoneRowIds(session).some(id => sessionRemovalIntersected(snapshot, id, normalizeProfileKey(session.profile)))

const kept = sessions.filter(session => !raced(session))

Expand Down Expand Up @@ -292,10 +292,13 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg

setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(
prev.filter(inPlatform),
carryForwardFailedProfileSessions(prev.filter(inPlatform), incoming, result.errors),
sessionsToKeep()
...dropRemovalRaced(
mergeSessionPage(
prev.filter(inPlatform),
carryForwardFailedProfileSessions(prev.filter(inPlatform), incoming, result.errors),
sessionsToKeep()
),
removalSnapshot
)
])

Expand Down
120 changes: 120 additions & 0 deletions apps/desktop/src/store/live-sync-deletion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { afterEach, expect, it } from 'vitest'

import { handleLifecycleEvent } from '@/app/session/hooks/use-message-stream/gateway-event/lifecycle'
import type { GatewayEventContext } from '@/app/session/hooks/use-message-stream/gateway-event/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { makeSessionInfo } from '@/test/session-info'

import {
$cronSessions,
$messagingSessions,
$sessions,
setCronSessions,
setMessagingSessions,
setSessions
} from './session'
import { notifySessionsDeleted } from './session-live-deletion'
import {
$removedSessionIds,
captureSessionTombstoneGenerations,
sessionRemovalIntersected,
untombstoneSessions
} from './session-removal'
import { $sessionStates, clearAllSessionStates, publishSessionState, recordSessionEventScope } from './session-states'

afterEach(() => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
clearAllSessionStates()
untombstoneSessions([...$removedSessionIds.get()])
})

it('evicts exact sidebar IDs synchronously and fences pages even after tree pruning', () => {
const snapshot = captureSessionTombstoneGenerations()
const rows = [makeSessionInfo({ id: 'delete-id' }), makeSessionInfo({ id: 'keep-id' })]
setSessions(rows)
setCronSessions(rows)
setMessagingSessions(rows)
notifySessionsDeleted({ session_ids: ['delete-id', 'not-loaded-id'], profile: 'default' })

for (const store of [$sessions, $cronSessions, $messagingSessions]) {
expect(store.get().map(row => row.id)).toEqual(['keep-id'])
}

// External events must never poison the legacy, profile-blind overlay.
expect($removedSessionIds.get().has('delete-id')).toBe(false)
// The tree's catch-up prune cannot let an older sidebar page resurrect rows.
$removedSessionIds.set(new Set())
expect(sessionRemovalIntersected(snapshot, 'delete-id')).toBe(true)
expect(sessionRemovalIntersected(snapshot, 'not-loaded-id')).toBe(true)
expect(sessionRemovalIntersected(snapshot, 'keep-id')).toBe(false)
})

it('fences an unseen deleted ID without fencing a foreign-profile page', () => {
const snapshot = captureSessionTombstoneGenerations()
notifySessionsDeleted({ session_ids: ['unseen-collision'], profile: 'default' })
expect(sessionRemovalIntersected(snapshot, 'unseen-collision', 'default')).toBe(true)
expect(sessionRemovalIntersected(snapshot, 'unseen-collision', 'other')).toBe(false)
// A fresh authoritative read may re-admit an ID restored after deletion.
expect(sessionRemovalIntersected(captureSessionTombstoneGenerations(), 'unseen-collision', 'default')).toBe(false)
})

it.each([setCronSessions, setMessagingSessions])('protects a live lineage tip in a dedicated sidebar list', setRows => {
setRows([makeSessionInfo({ id: 'live-tip', profile: 'default', _lineage_ids: ['live-root', 'live-tip'] })])
publishSessionState('lineage-writer', { ...createClientSessionState('live-root'), busy: true })
notifySessionsDeleted({ session_ids: ['live-tip'], profile: 'default' })
expect([...$cronSessions.get(), ...$messagingSessions.get()].map(row => row.id)).toEqual(['live-tip'])
})

it('does not let a known foreign writer block deletion of an owned twin', () => {
const snapshot = captureSessionTombstoneGenerations()
setSessions([makeSessionInfo({ id: 'writer-twin', profile: 'default' })])
recordSessionEventScope({ session_id: 'foreign-writer', connectionId: 'local', profile: 'other' })
publishSessionState('foreign-writer', { ...createClientSessionState('writer-twin'), busy: true })
notifySessionsDeleted({ session_ids: ['writer-twin'], profile: 'default' })
expect($sessions.get()).toHaveLength(0)
expect(sessionRemovalIntersected(snapshot, 'writer-twin', 'default')).toBe(true)
expect($sessionStates.get()['foreign-writer'].busy).toBe(true)
})

it('routes native deletion only from the active connection without touching transcript state', () => {
const payload = { session_ids: ['wire-id'], profile: 'default' }
setMessagingSessions([makeSessionInfo({ id: 'wire-id' })])
publishSessionState('idle-runtime', createClientSessionState('wire-id'))
const transcript = $sessionStates.get()

const context = {
event: { type: 'sessions.deleted', payload },
payload,
deps: {},
fromActiveSource: () => false
} as unknown as GatewayEventContext

expect(handleLifecycleEvent(context)).toBe(true)
expect($messagingSessions.get()).toHaveLength(1)
context.fromActiveSource = () => true
expect(handleLifecycleEvent(context)).toBe(true)
expect($messagingSessions.get()).toHaveLength(0)
expect($sessionStates.get()).toBe(transcript)
})

it.each(['busy', 'awaitingResponse', 'turnLive', 'needsInput'] as const)(
'preserves another profile and a %s writer',
flag => {
setMessagingSessions([
makeSessionInfo({ id: 'busy-id', profile: 'default' }),
makeSessionInfo({ id: 'other-id', profile: 'other' }),
makeSessionInfo({ id: 'collision-id', profile: 'default' }),
makeSessionInfo({ id: 'collision-id', profile: 'other' })
])
publishSessionState('busy-runtime', { ...createClientSessionState('busy-id'), [flag]: true })
notifySessionsDeleted({ session_ids: ['busy-id', 'other-id', 'collision-id'], profile: 'default' })
expect($messagingSessions.get().map(row => [row.id, row.profile])).toEqual([
['busy-id', 'default'],
['other-id', 'other'],
['collision-id', 'other']
])
expect($removedSessionIds.get().size).toBe(0)
}
)
33 changes: 33 additions & 0 deletions apps/desktop/src/store/projects.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,39 @@ describe('projects RPC profile forwarding', () => {
})
})

it('fences a stale project-tree preview by profile after external deletion', async () => {
const pending = deferred<unknown>()
const request = vi.fn(() => pending.promise)
const gateway = { connectionState: 'open', request }
activeGateway.mockReturnValue(gateway as never)
gatewayAtom.set(gateway as never)
$activeGatewayProfile.set('default')
const refresh = refreshProjectTree()
await waitFor(() => expect(request).toHaveBeenCalled())
const removals = await import('./session-removal')
removals.recordProfileSessionRemovals(['tree-twin'], 'default')
pending.resolve({
active_id: null,
scoped_session_ids: ['tree-twin'],
projects: [
{
id: 'p_external',
label: 'External',
path: '/external',
repos: [],
sessionCount: 2,
sessionIds: ['tree-twin'],
previewSessions: [
{ id: 'tree-twin', profile: 'default' },
{ id: 'tree-twin', profile: 'other' }
]
}
]
})
await refresh
expect($projectTree.get()[0].previewSessions?.map(s => s.profile)).toEqual(['other'])
})

it('keeps unchanged project nodes by reference across tree refreshes (#77591)', async () => {
const payload = () => ({
active_id: null,
Expand Down
24 changes: 19 additions & 5 deletions apps/desktop/src/store/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,15 +489,25 @@ function tagProjectSessionConnection(project: SidebarProjectTree, owner: Project
}
}

function applyProjectTreePayload(res: ProjectTreePayload, owner: ProjectRowOwner): void {
function applyProjectTreePayload(
res: ProjectTreePayload,
owner: ProjectRowOwner,
removalSnapshot: SessionTombstoneGenerationSnapshot
): void {
const scoped = new Set(res.scoped_session_ids ?? [])
// The tree refreshes on every sessions.changed and window focus, and most of
// those answers are unchanged. Keep unchanged nodes by reference so the
// entered project doesn't refetch and rebuild on a no-op (#77591).
$projectTree.set(
replaceEqualDeep(
$projectTree.get(),
(res.projects ?? []).map(project => tagProjectSessionConnection(project, owner))
(res.projects ?? []).map(project =>
excludeProjectSessions(tagProjectSessionConnection(project, owner), session =>
tombstoneRowIds(session).some(id =>
sessionRemovalIntersected(removalSnapshot, id, session.profile ?? 'default')
)
)
)
)
)
$activeProjectId.set(res.active_id ?? null)
Expand All @@ -518,6 +528,7 @@ function applyProjectTreePayload(res: ProjectTreePayload, owner: ProjectRowOwner

async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<void> {
const generation = ++projectTreeRefreshGeneration
const removalSnapshot = captureSessionTombstoneGenerations()
const { gateway, profile } = context

if (activeGateway() === gateway) {
Expand Down Expand Up @@ -553,7 +564,7 @@ async function refreshProjectTreeOn(context: ActiveProjectsContext): Promise<voi
return
}

applyProjectTreePayload(res, context)
applyProjectTreePayload(res, context, removalSnapshot)
markProjectsRpcSuccess()
} catch (err) {
if (generation === projectTreeRefreshGeneration && stillOnProjectsContext(context)) {
Expand Down Expand Up @@ -590,6 +601,7 @@ export async function refreshProjectTree(): Promise<void> {
async function refreshProjectTreeAcrossProfiles(): Promise<void> {
const owner = projectRowOwner()
const generation = ++projectTreeRefreshGeneration
const removalSnapshot = captureSessionTombstoneGenerations()
$projectTreeLoading.set(true)

try {
Expand All @@ -608,7 +620,7 @@ async function refreshProjectTreeAcrossProfiles(): Promise<void> {
return
}

applyProjectTreePayload(res, owner)
applyProjectTreePayload(res, owner, removalSnapshot)
markProjectsRpcSuccess()
} catch (err) {
markProjectsRpcFailure(err)
Expand Down Expand Up @@ -642,7 +654,9 @@ function dropRemovedProjectSessions(
const tombstones = $removedSessionIds.get()

return excludeProjectSessions(project, session =>
tombstoneRowIds(session).some(id => tombstones.has(id) || sessionRemovalIntersected(removalSnapshot, id))
tombstoneRowIds(session).some(
id => tombstones.has(id) || sessionRemovalIntersected(removalSnapshot, id, session.profile ?? 'default')
)
)
}

Expand Down
Loading