diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index b397502d..a9f9e414 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -727,6 +727,8 @@ function areMemberSpawnStatusEntriesEqual( ): boolean { if (left === right) return true; if (!left || !right) return left === right; + const leftPendingPermissionIds = [...(left.pendingPermissionRequestIds ?? [])].sort(); + const rightPendingPermissionIds = [...(right.pendingPermissionRequestIds ?? [])].sort(); // Renderer equality intentionally ignores raw timing fields that do not change // visible member status. This suppresses heartbeat-only churn in TeamDetailView. return ( @@ -738,7 +740,9 @@ function areMemberSpawnStatusEntriesEqual( left.runtimeAlive === right.runtimeAlive && left.runtimeModel === right.runtimeModel && left.bootstrapConfirmed === right.bootstrapConfirmed && - left.hardFailure === right.hardFailure + left.hardFailure === right.hardFailure && + leftPendingPermissionIds.length === rightPendingPermissionIds.length && + leftPendingPermissionIds.every((value, index) => value === rightPendingPermissionIds[index]) ); } diff --git a/test/renderer/store/teamSlice.test.ts b/test/renderer/store/teamSlice.test.ts index 670a44f9..ebdccc37 100644 --- a/test/renderer/store/teamSlice.test.ts +++ b/test/renderer/store/teamSlice.test.ts @@ -3612,6 +3612,74 @@ describe('teamSlice actions', () => { expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).toBe(previousSnapshot); }); + it('does not suppress spawn snapshots when pending permission request ids change', async () => { + const store = createSliceStore(); + const previousSnapshot = createMemberSpawnSnapshot({ + teamLaunchState: 'partial_pending', + launchPhase: 'active', + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + statuses: { + alice: createMemberSpawnStatus({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z', + lastHeartbeatAt: undefined, + }), + }, + }); + + store.setState({ + memberSpawnStatusesByTeam: { + 'my-team': previousSnapshot.statuses, + }, + memberSpawnSnapshotsByTeam: { + 'my-team': previousSnapshot, + }, + }); + + const nextSnapshot = createMemberSpawnSnapshot({ + teamLaunchState: 'partial_pending', + launchPhase: 'active', + summary: { + confirmedCount: 0, + pendingCount: 1, + failedCount: 0, + runtimeAlivePendingCount: 0, + }, + statuses: { + alice: createMemberSpawnStatus({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: false, + livenessSource: undefined, + bootstrapConfirmed: false, + firstSpawnAcceptedAt: '2026-03-12T09:59:30.000Z', + lastHeartbeatAt: undefined, + pendingPermissionRequestIds: ['perm-1'], + }), + }, + }); + hoisted.getMemberSpawnStatuses.mockResolvedValue(nextSnapshot); + + await store.getState().fetchMemberSpawnStatuses('my-team'); + + expect(store.getState().memberSpawnSnapshotsByTeam['my-team']).not.toBe(previousSnapshot); + expect(store.getState().memberSpawnStatusesByTeam['my-team']).not.toBe( + previousSnapshot.statuses + ); + expect( + store.getState().memberSpawnStatusesByTeam['my-team']?.alice?.pendingPermissionRequestIds + ).toEqual(['perm-1']); + }); + it('ignores stale spawn-status fetches after runtime already went offline', async () => { const store = createSliceStore(); store.setState({